AIAgent.cs 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090
  1. using UnityEngine;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. /// <summary>
  5. /// AI Agent that navigates the maze with limited knowledge
  6. /// Only knows about the room it's currently in and rooms visited by same character type
  7. /// </summary>
  8. public class AIAgent : MonoBehaviour
  9. {
  10. [Header("Agent Identity")]
  11. [SerializeField] private string agentCharacterType = "Default";
  12. [SerializeField] private int agentId;
  13. [Header("Agent Personalization")]
  14. private string agentName;
  15. private AgentStats agentStats;
  16. [Header("AI Settings")]
  17. [SerializeField] private float movementSpeed = 2f;
  18. [SerializeField] private float pathUpdateInterval = 0.5f;
  19. [SerializeField] private float stoppingDistance = 0.1f;
  20. private float actualMovementSpeed; // Per-agent speed variation to reduce synchronization
  21. [Header("Pathfinding")]
  22. [SerializeField] private bool showPath = true;
  23. [SerializeField] private LineRenderer pathRenderer;
  24. [SerializeField] private Color pathColor = Color.yellow;
  25. private MazeController mazeController;
  26. private MazeData maze;
  27. private MazePathfinder pathfinder;
  28. private AIRoomMemory roomMemory;
  29. private List<Vector2Int> currentPath = new();
  30. private int currentPathIndex = 0;
  31. private float lastPathUpdate = 0f;
  32. private Vector2Int currentRoom = Vector2Int.zero;
  33. private Vector2Int targetRoom = Vector2Int.zero;
  34. private Vector2Int targetExitTile = Vector2Int.zero; // Target hallway exit to move toward
  35. private Queue<int> recentRooms = new Queue<int>(); // Track last N rooms to avoid backtracking
  36. private const int RECENT_ROOMS_BUFFER_SIZE = 10; // Larger buffer = less backtracking
  37. private MazeRoom lastRoomExitedFrom = null; // Track which room we exited to prevent immediate backtracking
  38. private bool hasReachedGoal = false; // Track if agent has reached the goal room
  39. private bool commitedToExit = false; // Track if agent has committed to a specific hallway exit
  40. private float nextRandomWait = 0f; // Random wait time before exploring new areas
  41. private float agentRandomOffset = 0f; // Per-agent random offset to desync movement
  42. // Intelligence-driven disposition (seeded at spawn from Intelligence stat)
  43. // Will drive group-up logic, risk assessment, and future combat decisions
  44. private float groupUpAffinity; // 0-1: likelihood to seek allies over going solo
  45. private float riskTolerance; // 0-1: willingness to enter dangerous/unknown situations
  46. private bool knowsExitLocation = false; // True once agent has "seen" the exit room
  47. // ----- Group membership -----
  48. private AgentGroup currentGroup = null;
  49. private bool isGroupFollower = false; // True when this agent defers movement to the leader
  50. void Start()
  51. {
  52. mazeController = FindAnyObjectByType<MazeController>();
  53. if (mazeController == null)
  54. {
  55. Debug.LogError("AIAgent: MazeController not found in scene!");
  56. enabled = false;
  57. return;
  58. }
  59. maze = mazeController.GetCurrentMaze();
  60. if (maze == null)
  61. {
  62. Debug.LogError("AIAgent: Current maze is null!");
  63. enabled = false;
  64. return;
  65. }
  66. pathfinder = new MazePathfinder(maze);
  67. roomMemory = AIRoomMemoryManager.GetMemory(agentCharacterType);
  68. // Initialize personalization
  69. agentName = AgentNameGenerator.GenerateRandomName();
  70. agentStats = new AgentStats();
  71. gameObject.name = $"Agent_{agentId} ({agentName})";
  72. // Seed intelligence-driven disposition from stats
  73. groupUpAffinity = agentStats.GroupUpAffinity;
  74. riskTolerance = agentStats.RiskTolerance;
  75. // Initialize with a random start point
  76. if (maze.StartPoints.Count > 0)
  77. {
  78. int startIndex = agentId % maze.StartPoints.Count; // Distribute agents across start points
  79. Vector2Int startPos = maze.StartPoints[startIndex];
  80. // Get the start room and spawn randomly within it
  81. MazeRoom startRoom = maze.GetRoomAtTile(startPos.x, startPos.y);
  82. if (startRoom != null)
  83. {
  84. // Spawn at random position within start room
  85. Vector2Int randomPosInRoom = new Vector2Int(
  86. Random.Range(startRoom.MinX + 1, startRoom.MaxX),
  87. Random.Range(startRoom.MinY + 1, startRoom.MaxY)
  88. );
  89. transform.position = new Vector3(randomPosInRoom.x + 0.5f, 1f, randomPosInRoom.y + 0.5f);
  90. }
  91. else
  92. {
  93. // Fallback to exact start point
  94. transform.position = new Vector3(startPos.x + 0.5f, 1f, startPos.y + 0.5f);
  95. }
  96. // Rotate to be visible from above - 90 degrees around X axis
  97. transform.rotation = Quaternion.Euler(90, 0, 0);
  98. UpdateCurrentRoom(); // This will add to recentRooms and visit room
  99. }
  100. else
  101. {
  102. Debug.LogError("AIAgent: No start points found in maze!");
  103. enabled = false;
  104. return;
  105. }
  106. // Setup per-agent random offset to desync decision timings (prevents all agents moving in sync)
  107. agentRandomOffset = Random.Range(0f, pathUpdateInterval * 0.5f);
  108. nextRandomWait = Time.time + Random.Range(2f, 4f); // Random initial wait before first decision
  109. // Scale movement speed by the agent's Speed stat:
  110. // Speed=1 → ~1× base, Speed=100 → 2× base, plus ±10% desync jitter
  111. float speedMultiplier = 1f + (agentStats.Speed / 100f);
  112. actualMovementSpeed = movementSpeed * speedMultiplier * Random.Range(0.9f, 1.1f);
  113. Debug.Log($"[Agent {agentId}] {agentName} | {agentStats} | GroupUp: {groupUpAffinity:P0} | Risk: {riskTolerance:P0}");
  114. // Setup path renderer
  115. if (showPath && pathRenderer == null)
  116. {
  117. pathRenderer = gameObject.AddComponent<LineRenderer>();
  118. pathRenderer.startWidth = 0.1f;
  119. pathRenderer.endWidth = 0.1f;
  120. pathRenderer.material = new Material(Shader.Find("Sprites/Default"));
  121. pathRenderer.startColor = pathColor;
  122. pathRenderer.endColor = pathColor;
  123. }
  124. }
  125. void Update()
  126. {
  127. if (maze == null) return;
  128. // Followers defer all movement to the group leader
  129. if (isGroupFollower)
  130. {
  131. if (currentGroup != null && currentGroup.Leader != null)
  132. transform.position = currentGroup.Leader.transform.position;
  133. return;
  134. }
  135. // Update current room
  136. UpdateCurrentRoom();
  137. // Update pathfinding periodically with agent-specific offset to desync behavior
  138. if (Time.time - lastPathUpdate > pathUpdateInterval + agentRandomOffset)
  139. {
  140. UpdatePathToGoal();
  141. lastPathUpdate = Time.time;
  142. }
  143. // Move along path
  144. FollowPath();
  145. // Debug visualization
  146. if (showPath && pathRenderer != null)
  147. {
  148. UpdatePathVisualization();
  149. }
  150. }
  151. /// <summary>
  152. /// Updates the current room based on position
  153. /// </summary>
  154. private void UpdateCurrentRoom()
  155. {
  156. Vector2Int tilePos = WorldToTile(transform.position);
  157. MazeRoom room = maze.GetRoomAtTile(tilePos.x, tilePos.y);
  158. if (room != null)
  159. {
  160. if (currentRoom.x != room.Id)
  161. {
  162. currentRoom = new Vector2Int(room.Id, 0);
  163. roomMemory.VisitRoom(room.Id);
  164. // Track recent rooms to avoid immediate backtracking
  165. recentRooms.Enqueue(room.Id);
  166. if (recentRooms.Count > RECENT_ROOMS_BUFFER_SIZE)
  167. recentRooms.Dequeue();
  168. }
  169. }
  170. }
  171. /// <summary>
  172. /// Updates pathfinding to reach the goal
  173. /// Agent only knows about current room and visited rooms
  174. /// NEW LOGIC: Pick a hallway exit from current room and move straight to it
  175. /// </summary>
  176. private void UpdatePathToGoal()
  177. {
  178. if (maze.ExitPoints.Count == 0)
  179. {
  180. Debug.LogWarning($"AIAgent {agentId}: No exit points in maze!");
  181. return;
  182. }
  183. Vector2Int currentPos = WorldToTile(transform.position);
  184. MazeRoom currentRoomData = maze.GetRoomAtTile(currentPos.x, currentPos.y);
  185. // CHECK FOR GOAL ROOM FIRST - this should work even while following path
  186. var goalRooms = maze.GetRoomsByType(MazeRoom.RoomType.End);
  187. if (currentRoomData != null && goalRooms.Contains(currentRoomData))
  188. {
  189. if (!hasReachedGoal)
  190. {
  191. hasReachedGoal = true;
  192. currentPath.Clear();
  193. currentPathIndex = 0;
  194. commitedToExit = false;
  195. // If this agent is in a group, mark all members as having reached the goal
  196. if (currentGroup != null)
  197. {
  198. foreach (var member in currentGroup.Members)
  199. {
  200. if (member != null && !member.HasReachedGoal)
  201. {
  202. member.hasReachedGoal = true;
  203. }
  204. }
  205. }
  206. }
  207. return; // Stay stopped
  208. }
  209. // If we already have a valid path and we're following it, don't recalculate (stay committed)
  210. if (currentPath.Count > 0 && currentPathIndex < currentPath.Count)
  211. {
  212. return; // Keep following existing path
  213. }
  214. // If we've committed to reaching an exit and haven't reached it yet, keep trying
  215. if (commitedToExit && targetExitTile != Vector2Int.zero)
  216. {
  217. // Try to find a path to the committed exit
  218. if (currentPath.Count == 0)
  219. {
  220. currentPath = FindPathInRoom(currentPos, targetExitTile, currentRoomData);
  221. currentPathIndex = 0;
  222. if (currentPath.Count > 0)
  223. {
  224. Debug.Log($"AIAgent {agentId}: Re-committed to exit {targetExitTile}");
  225. return;
  226. }
  227. }
  228. }
  229. // If in hallway (no room), just keep moving along current path
  230. // The hallway pathfinding will naturally move us toward exits
  231. if (currentRoomData == null)
  232. {
  233. // If we have a current path, keep following it through hallway
  234. if (currentPath.Count > 0 && currentPathIndex < currentPath.Count)
  235. {
  236. return; // Keep following path
  237. }
  238. // In hallway with no path - find next room to enter
  239. // PRIORITY 1: Find unvisited rooms, avoid the room we just exited
  240. // Collect all unvisited rooms
  241. List<MazeRoom> unvisitedRooms = new();
  242. foreach (var room in maze.Rooms)
  243. {
  244. // Skip the room we just exited (backtracking prevention)
  245. if (lastRoomExitedFrom != null && room.Id == lastRoomExitedFrom.Id)
  246. continue;
  247. // Prioritize unvisited rooms
  248. if (!roomMemory.HasVisited(room.Id))
  249. {
  250. unvisitedRooms.Add(room);
  251. }
  252. }
  253. MazeRoom targetRoom = null;
  254. // Random decision: 70% closest unvisited, 30% random unvisited (increases exploration variety)
  255. if (unvisitedRooms.Count > 0)
  256. {
  257. if (Random.value < 0.7f && unvisitedRooms.Count > 0)
  258. {
  259. // Pick closest unvisited room
  260. float closestUnvisitedDistance = float.MaxValue;
  261. foreach (var room in unvisitedRooms)
  262. {
  263. Vector2Int roomCenter = room.GetCenter();
  264. float distance = Vector2Int.Distance(currentPos, roomCenter);
  265. if (distance < closestUnvisitedDistance)
  266. {
  267. closestUnvisitedDistance = distance;
  268. targetRoom = room;
  269. }
  270. }
  271. }
  272. else
  273. {
  274. // Pick random unvisited room for variety
  275. targetRoom = unvisitedRooms[Random.Range(0, unvisitedRooms.Count)];
  276. }
  277. }
  278. // If no unvisited room found, pick nearest room (except the one we came from)
  279. if (targetRoom == null)
  280. {
  281. float closestDistance = float.MaxValue;
  282. foreach (var room in maze.Rooms)
  283. {
  284. // Skip the room we just exited
  285. if (lastRoomExitedFrom != null && room.Id == lastRoomExitedFrom.Id)
  286. continue;
  287. Vector2Int roomCenter = room.GetCenter();
  288. float distance = Vector2Int.Distance(currentPos, roomCenter);
  289. if (distance < closestDistance)
  290. {
  291. closestDistance = distance;
  292. targetRoom = room;
  293. }
  294. }
  295. }
  296. // Exit-room caution: if the chosen target is the exit room and we spotted
  297. // it through a short corridor, smarter agents may hesitate or route around.
  298. // (Low intelligence agents rush straight in; high intelligence agents are cautious
  299. // and may wait for allies — once group logic is implemented.)
  300. if (targetRoom != null)
  301. {
  302. var goalRoomsCheck = maze.GetRoomsByType(MazeRoom.RoomType.End);
  303. if (goalRoomsCheck.Contains(targetRoom))
  304. {
  305. knowsExitLocation = true;
  306. // High-intelligence agents (INT > 50) pause briefly to "assess"
  307. // before committing — placeholder for future ally/fight evaluation
  308. if (agentStats.Intelligence > 50)
  309. {
  310. nextRandomWait = Time.time + (agentStats.Intelligence / 100f) * 2f;
  311. }
  312. }
  313. }
  314. if (targetRoom != null)
  315. {
  316. currentPath = FindPathToNearestRoom(currentPos, targetRoom);
  317. currentPathIndex = 0;
  318. if (currentPath.Count > 0)
  319. {
  320. bool isUnvisited = !roomMemory.HasVisited(targetRoom.Id);
  321. return;
  322. }
  323. else
  324. {
  325. // Debug.LogWarning($"AIAgent {agentId}: Failed to path to room {targetRoom.Id} from hallway at {currentPos}. Trying any adjacent room.");
  326. // Fallback: try to find ANY adjacent room and move directly toward it
  327. foreach (var room in maze.Rooms)
  328. {
  329. if (lastRoomExitedFrom != null && room.Id == lastRoomExitedFrom.Id)
  330. continue;
  331. currentPath = FindPathToNearestRoom(currentPos, room);
  332. if (currentPath.Count > 0)
  333. {
  334. return;
  335. }
  336. }
  337. Debug.LogError($"AIAgent {agentId}: STUCK in hallway at {currentPos} - no paths found to any room!");
  338. return;
  339. }
  340. }
  341. Debug.LogWarning($"AIAgent {agentId}: In hallway but no reachable rooms!");
  342. return;
  343. }
  344. // MAIN LOGIC: In regular room, find a hallway exit and path to it
  345. // Get all hallway tiles adjacent to this room
  346. List<Vector2Int> hallwayExits = FindHallwayExits(currentRoomData, currentPos);
  347. if (hallwayExits.Count > 0)
  348. {
  349. // Peek through each exit: if the corridor leads directly to the exit room
  350. // and the agent has enough intelligence, avoid that exit for now and pick another.
  351. List<Vector2Int> safeExits = new List<Vector2Int>(hallwayExits);
  352. if (!knowsExitLocation)
  353. {
  354. var goalRoomsList = maze.GetRoomsByType(MazeRoom.RoomType.End);
  355. foreach (var exit in hallwayExits)
  356. {
  357. MazeRoom peekedRoom = PeekCorridorDestination(exit, currentRoomData);
  358. if (peekedRoom != null && goalRoomsList.Contains(peekedRoom))
  359. {
  360. knowsExitLocation = true;
  361. // Low-intelligence agents rush the exit; high-intelligence ones
  362. // reconsider (they may want allies or a better moment)
  363. if (agentStats.Intelligence > 40 && safeExits.Count > 1)
  364. {
  365. safeExits.Remove(exit); // Avoid this corridor for now
  366. }
  367. }
  368. }
  369. }
  370. if (safeExits.Count == 0) safeExits = hallwayExits; // Fallback: all exits
  371. // Pick a random hallway exit from the (possibly filtered) list
  372. Vector2Int chosenExit = safeExits[Random.Range(0, safeExits.Count)];
  373. // Path directly to that exit
  374. currentPath = FindPathInRoom(currentPos, chosenExit, currentRoomData);
  375. currentPathIndex = 0;
  376. if (currentPath.Count > 0)
  377. {
  378. targetExitTile = chosenExit;
  379. commitedToExit = true;
  380. // Add random delay before next decision to increase variety
  381. nextRandomWait = Time.time + Random.Range(1.5f, 3.5f);
  382. }
  383. else
  384. {
  385. Debug.LogWarning($"AIAgent {agentId}: Could not path to hallway exit");
  386. commitedToExit = false;
  387. }
  388. }
  389. else
  390. {
  391. Debug.LogWarning($"AIAgent {agentId}: No hallway exits found from room {currentRoomData.Id}");
  392. commitedToExit = false;
  393. }
  394. }
  395. /// <summary>
  396. /// Chooses the next room to move to
  397. /// Searches around the current room to find adjacent rooms
  398. /// Avoids recently visited rooms to prevent backtracking
  399. /// </summary>
  400. private MazeRoom ChooseNextRoom(MazeRoom currentRoom)
  401. {
  402. // Find all adjacent rooms by checking walkable tiles in all directions from room boundaries
  403. List<MazeRoom> connectedRooms = new();
  404. Vector2Int roomCenter = currentRoom.GetCenter();
  405. // Check from each direction outward from the room center
  406. Vector2Int[] directions = new Vector2Int[]
  407. {
  408. Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right,
  409. Vector2Int.up + Vector2Int.left, Vector2Int.up + Vector2Int.right,
  410. Vector2Int.down + Vector2Int.left, Vector2Int.down + Vector2Int.right
  411. };
  412. // Shuffle directions for randomness
  413. for (int i = directions.Length - 1; i > 0; i--)
  414. {
  415. int randomIndex = Random.Range(0, i + 1);
  416. var temp = directions[i];
  417. directions[i] = directions[randomIndex];
  418. directions[randomIndex] = temp;
  419. }
  420. // Try each direction and look for tiles that lead to other rooms
  421. foreach (var dir in directions)
  422. {
  423. for (int dist = 1; dist < 20; dist++) // Search up to 20 tiles away
  424. {
  425. Vector2Int testPos = roomCenter + (dir * dist);
  426. if (!maze.IsInBounds(testPos.x, testPos.y) || !maze.IsWalkable(testPos.x, testPos.y))
  427. continue;
  428. MazeRoom testRoom = maze.GetRoomAtTile(testPos.x, testPos.y);
  429. if (testRoom != null && testRoom.Id != currentRoom.Id && !connectedRooms.Contains(testRoom))
  430. {
  431. connectedRooms.Add(testRoom);
  432. break; // Found a room in this direction, move to next direction
  433. }
  434. }
  435. }
  436. if (connectedRooms.Count == 0)
  437. {
  438. Debug.LogWarning($"AIAgent {agentId}: No adjacent rooms found from room {currentRoom.Id}");
  439. return null;
  440. }
  441. // Filter out recently visited rooms (to avoid backtracking)
  442. List<MazeRoom> nonRecentRooms = connectedRooms.Where(r => !recentRooms.Contains(r.Id)).ToList();
  443. // PRIORITY 1: Strongly prefer completely unvisited rooms (not recently visited either)
  444. var completelyUnvisited = nonRecentRooms.Where(r => !roomMemory.HasVisited(r.Id)).ToList();
  445. if (completelyUnvisited.Count > 0)
  446. {
  447. MazeRoom chosen = completelyUnvisited[Random.Range(0, completelyUnvisited.Count)];
  448. return chosen;
  449. }
  450. // PRIORITY 2: Try non-recent rooms even if visited by this character type
  451. if (nonRecentRooms.Count > 0)
  452. {
  453. // Among non-recent rooms, prefer unvisited by this agent's character type
  454. var unvisitedByType = nonRecentRooms.Where(r => !roomMemory.HasVisited(r.Id)).ToList();
  455. if (unvisitedByType.Count > 0)
  456. {
  457. MazeRoom chosen = unvisitedByType[Random.Range(0, unvisitedByType.Count)];
  458. return chosen;
  459. }
  460. // Otherwise just pick a random non-recent room
  461. MazeRoom chosen2 = nonRecentRooms[Random.Range(0, nonRecentRooms.Count)];
  462. return chosen2;
  463. }
  464. // PRIORITY 3: If all rooms are recent, pick one that's been visited least recently
  465. // Find the room in connectedRooms that was added to recentRooms earliest (will be dequeued first)
  466. MazeRoom leastRecentRoom = connectedRooms[0];
  467. foreach (var room in connectedRooms)
  468. {
  469. if (!roomMemory.HasVisited(room.Id))
  470. {
  471. leastRecentRoom = room;
  472. break;
  473. }
  474. }
  475. Debug.Log($"AIAgent {agentId}: All rooms recent, choosing least-recent room {leastRecentRoom.Id}");
  476. return leastRecentRoom;
  477. }
  478. /// <summary>
  479. /// Finds the nearest room from a position (used when in hallway)
  480. /// </summary>
  481. private MazeRoom FindNearestRoom(Vector2Int position)
  482. {
  483. MazeRoom nearest = null;
  484. float nearestDistance = float.MaxValue;
  485. foreach (var room in maze.Rooms)
  486. {
  487. Vector2Int roomCenter = room.GetCenter();
  488. float distance = Vector2Int.Distance(position, roomCenter);
  489. if (distance < nearestDistance)
  490. {
  491. nearestDistance = distance;
  492. nearest = room;
  493. }
  494. }
  495. return nearest;
  496. }
  497. /// <summary>
  498. /// Finds all hallway exit tiles adjacent to a room
  499. /// These are walkable tiles just outside the room boundary
  500. /// </summary>
  501. /// <summary>
  502. /// Peeks along a corridor starting from a hallway exit tile.
  503. /// Follows walkable non-room tiles up to a short distance and returns
  504. /// the first room found at the other end, or null if no room is close.
  505. /// Used by intelligence logic to detect if an exit leads straight to the goal.
  506. /// </summary>
  507. private MazeRoom PeekCorridorDestination(Vector2Int exitTile, MazeRoom sourceRoom, int maxPeekDistance = 12)
  508. {
  509. // BFS outward from the exit tile, staying in non-room (hallway) tiles
  510. var visited = new HashSet<Vector2Int> { exitTile };
  511. var queue = new Queue<Vector2Int>();
  512. queue.Enqueue(exitTile);
  513. while (queue.Count > 0)
  514. {
  515. Vector2Int tile = queue.Dequeue();
  516. Vector2Int[] dirs = {
  517. new(tile.x + 1, tile.y), new(tile.x - 1, tile.y),
  518. new(tile.x, tile.y + 1), new(tile.x, tile.y - 1)
  519. };
  520. foreach (var next in dirs)
  521. {
  522. if (!maze.IsInBounds(next.x, next.y) || !maze.IsWalkable(next.x, next.y)) continue;
  523. if (visited.Contains(next)) continue;
  524. MazeRoom nextRoom = maze.GetRoomAtTile(next.x, next.y);
  525. if (nextRoom != null && nextRoom.Id != sourceRoom.Id)
  526. return nextRoom; // Found the room at the end of this corridor
  527. // Only continue through hallway tiles and within peek distance
  528. if (nextRoom == null && Vector2Int.Distance(exitTile, next) < maxPeekDistance)
  529. {
  530. visited.Add(next);
  531. queue.Enqueue(next);
  532. }
  533. }
  534. }
  535. return null;
  536. }
  537. private List<Vector2Int> FindHallwayExits(MazeRoom room, Vector2Int currentPos)
  538. {
  539. List<Vector2Int> exits = new();
  540. HashSet<Vector2Int> addedExits = new();
  541. // Check all tiles on the boundary of the room
  542. // North boundary
  543. for (int x = room.MinX; x <= room.MaxX; x++)
  544. {
  545. int y = room.MinY - 1;
  546. if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y)))
  547. {
  548. exits.Add(new Vector2Int(x, y));
  549. addedExits.Add(new Vector2Int(x, y));
  550. }
  551. }
  552. // South boundary
  553. for (int x = room.MinX; x <= room.MaxX; x++)
  554. {
  555. int y = room.MaxY + 1;
  556. if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y)))
  557. {
  558. exits.Add(new Vector2Int(x, y));
  559. addedExits.Add(new Vector2Int(x, y));
  560. }
  561. }
  562. // West boundary
  563. for (int y = room.MinY; y <= room.MaxY; y++)
  564. {
  565. int x = room.MinX - 1;
  566. if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y)))
  567. {
  568. exits.Add(new Vector2Int(x, y));
  569. addedExits.Add(new Vector2Int(x, y));
  570. }
  571. }
  572. // East boundary
  573. for (int y = room.MinY; y <= room.MaxY; y++)
  574. {
  575. int x = room.MaxX + 1;
  576. if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y)))
  577. {
  578. exits.Add(new Vector2Int(x, y));
  579. addedExits.Add(new Vector2Int(x, y));
  580. }
  581. }
  582. return exits;
  583. }
  584. /// <summary>
  585. /// Finds a path from current position to the nearest room (when in hallway)
  586. /// </summary>
  587. private List<Vector2Int> FindPathToNearestRoom(Vector2Int start, MazeRoom targetRoom)
  588. {
  589. // Simple pathfinding through hallways using A*
  590. var openSet = new List<Vector2Int> { start };
  591. var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
  592. var gScore = new Dictionary<Vector2Int, float> { [start] = 0 };
  593. var targetCenter = targetRoom.GetCenter();
  594. var fScore = new Dictionary<Vector2Int, float> { [start] = Vector2Int.Distance(start, targetCenter) };
  595. int iterations = 0;
  596. const int maxIterations = 2000; // Increased from 500 to handle larger mazes
  597. while (openSet.Count > 0 && iterations < maxIterations)
  598. {
  599. iterations++;
  600. // Find node with lowest fScore
  601. Vector2Int current = openSet[0];
  602. float lowestF = fScore[current];
  603. for (int i = 1; i < openSet.Count; i++)
  604. {
  605. if (fScore[openSet[i]] < lowestF)
  606. {
  607. current = openSet[i];
  608. lowestF = fScore[current];
  609. }
  610. }
  611. // If we reached the target room, we're done
  612. MazeRoom currentRoom = maze.GetRoomAtTile(current.x, current.y);
  613. if (currentRoom != null && currentRoom.Id == targetRoom.Id)
  614. {
  615. return ReconstructPath(cameFrom, current);
  616. }
  617. openSet.Remove(current);
  618. // Check neighbors
  619. Vector2Int[] neighbors = new[]
  620. {
  621. new Vector2Int(current.x + 1, current.y),
  622. new Vector2Int(current.x - 1, current.y),
  623. new Vector2Int(current.x, current.y + 1),
  624. new Vector2Int(current.x, current.y - 1),
  625. };
  626. foreach (var neighbor in neighbors)
  627. {
  628. if (!maze.IsInBounds(neighbor.x, neighbor.y) || !maze.IsWalkable(neighbor.x, neighbor.y))
  629. continue;
  630. float tentativeG = gScore[current] + 1;
  631. if (!gScore.ContainsKey(neighbor) || tentativeG < gScore[neighbor])
  632. {
  633. cameFrom[neighbor] = current;
  634. gScore[neighbor] = tentativeG;
  635. fScore[neighbor] = tentativeG + Vector2Int.Distance(neighbor, targetCenter);
  636. if (!openSet.Contains(neighbor))
  637. {
  638. openSet.Add(neighbor);
  639. }
  640. }
  641. }
  642. }
  643. // Debug.LogWarning($"AIAgent {agentId}: FindPathToNearestRoom FAILED after {iterations} iterations. Start: {start}, Target room {targetRoom.Id} center: {targetCenter}");
  644. return new List<Vector2Int>();
  645. }
  646. /// <summary>
  647. /// Finds a boundary tile of the current room that points toward the next room
  648. /// </summary>
  649. private Vector2Int FindBoundaryTileToward(MazeRoom currentRoom, MazeRoom nextRoom, Vector2Int currentPos)
  650. {
  651. Vector2Int nextRoomCenter = nextRoom.GetCenter();
  652. Vector2Int closestBoundary = currentRoom.GetCenter();
  653. float closestDistance = float.MaxValue;
  654. // Check all boundary tiles of current room
  655. for (int x = currentRoom.MinX; x <= currentRoom.MaxX; x++)
  656. {
  657. for (int y = currentRoom.MinY; y <= currentRoom.MaxY; y++)
  658. {
  659. // Only check boundary and walkable tiles
  660. if ((x == currentRoom.MinX || x == currentRoom.MaxX || y == currentRoom.MinY || y == currentRoom.MaxY) &&
  661. maze.IsWalkable(x, y))
  662. {
  663. // Find boundary tile closest to next room center
  664. float distToNext = Vector2Int.Distance(new Vector2Int(x, y), nextRoomCenter);
  665. if (distToNext < closestDistance)
  666. {
  667. closestDistance = distToNext;
  668. closestBoundary = new Vector2Int(x, y);
  669. }
  670. }
  671. }
  672. }
  673. return closestBoundary;
  674. }
  675. /// <summary>
  676. /// Finds a path within a single room (limited knowledge)
  677. /// </summary>
  678. private List<Vector2Int> FindPathInRoom(Vector2Int start, Vector2Int goal, MazeRoom room)
  679. {
  680. // Use simple A* within room bounds
  681. var openSet = new List<Vector2Int> { start };
  682. var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
  683. var gScore = new Dictionary<Vector2Int, float> { [start] = 0 };
  684. var fScore = new Dictionary<Vector2Int, float> { [start] = Vector2Int.Distance(start, goal) };
  685. int iterations = 0;
  686. const int maxIterations = 1000;
  687. while (openSet.Count > 0 && iterations < maxIterations)
  688. {
  689. iterations++;
  690. // Find node with lowest fScore
  691. Vector2Int current = openSet[0];
  692. float lowestF = fScore[current];
  693. for (int i = 1; i < openSet.Count; i++)
  694. {
  695. if (fScore[openSet[i]] < lowestF)
  696. {
  697. current = openSet[i];
  698. lowestF = fScore[current];
  699. }
  700. }
  701. if (current == goal)
  702. {
  703. return ReconstructPath(cameFrom, current);
  704. }
  705. openSet.Remove(current);
  706. // Check only neighbors within the room
  707. var neighbors = GetRoomNeighbors(current, room);
  708. foreach (var neighbor in neighbors)
  709. {
  710. float tentativeG = gScore[current] + 1;
  711. if (!gScore.ContainsKey(neighbor) || tentativeG < gScore[neighbor])
  712. {
  713. cameFrom[neighbor] = current;
  714. gScore[neighbor] = tentativeG;
  715. fScore[neighbor] = tentativeG + Vector2Int.Distance(neighbor, goal);
  716. if (!openSet.Contains(neighbor))
  717. {
  718. openSet.Add(neighbor);
  719. }
  720. }
  721. }
  722. }
  723. return new List<Vector2Int>();
  724. }
  725. /// <summary>
  726. /// Gets walkable neighbors within a room or at room boundary
  727. /// Allows pathfinding to reach hallway exits outside room
  728. /// </summary>
  729. private List<Vector2Int> GetRoomNeighbors(Vector2Int position, MazeRoom room)
  730. {
  731. var neighbors = new List<Vector2Int>();
  732. Vector2Int[] directions = new[]
  733. {
  734. new Vector2Int(position.x + 1, position.y),
  735. new Vector2Int(position.x - 1, position.y),
  736. new Vector2Int(position.x, position.y + 1),
  737. new Vector2Int(position.x, position.y - 1),
  738. };
  739. foreach (var dir in directions)
  740. {
  741. // Allow tiles within room OR immediately adjacent to room (boundary)
  742. bool inBounds = maze.IsInBounds(dir.x, dir.y);
  743. bool isWalkable = maze.IsWalkable(dir.x, dir.y);
  744. bool inRoom = room.Contains(dir.x, dir.y);
  745. bool nearBoundary = (dir.x == room.MinX - 1 || dir.x == room.MaxX + 1 ||
  746. dir.y == room.MinY - 1 || dir.y == room.MaxY + 1);
  747. if (inBounds && isWalkable && (inRoom || nearBoundary))
  748. {
  749. neighbors.Add(dir);
  750. }
  751. }
  752. return neighbors;
  753. }
  754. /// <summary>
  755. /// Reconstructs path from A* results
  756. /// </summary>
  757. private List<Vector2Int> ReconstructPath(Dictionary<Vector2Int, Vector2Int> cameFrom, Vector2Int current)
  758. {
  759. var path = new List<Vector2Int> { current };
  760. while (cameFrom.ContainsKey(current))
  761. {
  762. current = cameFrom[current];
  763. path.Insert(0, current);
  764. }
  765. return path;
  766. }
  767. /// <summary>
  768. /// Follows the current path
  769. /// </summary>
  770. private void FollowPath()
  771. {
  772. if (currentPath.Count == 0) return;
  773. Vector2Int currentTarget = currentPath[currentPathIndex];
  774. // Maze coordinates: X,Y → World: X,Z (Y=0 is ground level)
  775. // Keep Y at 1f to stay above the maze floor
  776. Vector3 targetWorldPos = new Vector3(currentTarget.x + 0.5f, 1f, currentTarget.y + 0.5f);
  777. Vector3 direction = (targetWorldPos - transform.position).normalized;
  778. // Move directly instead of using Translate (no rigidbody)
  779. transform.position += direction * actualMovementSpeed * Time.deltaTime;
  780. // Check if we've exited the room (entered hallway)
  781. Vector2Int currentPos = WorldToTile(transform.position);
  782. MazeRoom roomAtPos = maze.GetRoomAtTile(currentPos.x, currentPos.y);
  783. if (roomAtPos == null && currentRoom.x != -1)
  784. {
  785. // We've entered a hallway - track which room we came from to prevent immediate backtracking
  786. lastRoomExitedFrom = maze.Rooms.FirstOrDefault(r => r.Id == currentRoom.x);
  787. commitedToExit = false; // No longer committed to that exit, now in hallway
  788. targetExitTile = Vector2Int.zero; // Clear the target exit
  789. }
  790. // Move to next waypoint when close enough
  791. if (Vector3.Distance(transform.position, targetWorldPos) < stoppingDistance)
  792. {
  793. currentPathIndex++;
  794. if (currentPathIndex >= currentPath.Count)
  795. {
  796. currentPath.Clear();
  797. }
  798. }
  799. }
  800. /// <summary>
  801. /// Updates the path visualization
  802. /// </summary>
  803. private void UpdatePathVisualization()
  804. {
  805. if (currentPath.Count == 0)
  806. {
  807. pathRenderer.positionCount = 0;
  808. return;
  809. }
  810. var positions = new Vector3[currentPath.Count];
  811. for (int i = 0; i < currentPath.Count; i++)
  812. {
  813. // Maze coordinates: X,Y → World: X,Z (Y=0 is ground level)
  814. // Keep Y at 1f to stay above the maze floor
  815. positions[i] = new Vector3(currentPath[i].x + 0.5f, 1f, currentPath[i].y + 0.5f);
  816. }
  817. pathRenderer.positionCount = positions.Length;
  818. pathRenderer.SetPositions(positions);
  819. }
  820. /// <summary>
  821. /// Converts world position to tile coordinate
  822. /// Maze coordinates: X,Y ← World: X,Z (Y=0 is ground level)
  823. /// </summary>
  824. private Vector2Int WorldToTile(Vector3 worldPos)
  825. {
  826. return new Vector2Int(Mathf.FloorToInt(worldPos.x), Mathf.FloorToInt(worldPos.z));
  827. }
  828. /// <summary>
  829. /// Gets agent ID
  830. /// </summary>
  831. public int AgentId => agentId;
  832. /// <summary>
  833. /// Gets agent character type
  834. /// </summary>
  835. public string CharacterType => agentCharacterType;
  836. /// <summary>
  837. /// Gets current room
  838. /// </summary>
  839. public Vector2Int CurrentRoom => currentRoom;
  840. /// <summary>
  841. /// Gets room memory
  842. /// </summary>
  843. public AIRoomMemory RoomMemory => roomMemory;
  844. /// <summary>
  845. /// Gets agent ID (public accessor for triggers)
  846. /// </summary>
  847. public int GetAgentId() => agentId;
  848. /// <summary>
  849. /// Called by ExitRoomTrigger when agent enters the goal room
  850. /// Immediately stops all movement
  851. /// Also marks all group members as having reached the goal
  852. /// </summary>
  853. public void StopAtGoal()
  854. {
  855. hasReachedGoal = true;
  856. currentPath.Clear();
  857. currentPathIndex = 0;
  858. commitedToExit = false;
  859. // If this agent is in a group, mark all members as having reached the goal
  860. if (currentGroup != null)
  861. {
  862. foreach (var member in currentGroup.Members)
  863. {
  864. if (member != null && !member.HasReachedGoal)
  865. {
  866. member.hasReachedGoal = true;
  867. }
  868. }
  869. }
  870. }
  871. /// <summary>
  872. /// Gets whether this agent has reached the goal
  873. /// </summary>
  874. public bool HasReachedGoal => hasReachedGoal;
  875. /// <summary>
  876. /// Gets agent's personalized name
  877. /// </summary>
  878. public string AgentName => agentName;
  879. /// <summary>
  880. /// Gets agent's stats
  881. /// </summary>
  882. public AgentStats Stats => agentStats;
  883. /// <summary>
  884. /// 0-1 tendency to seek allies over going solo (driven by Intelligence)
  885. /// </summary>
  886. public float GroupUpAffinity => groupUpAffinity;
  887. /// <summary>
  888. /// 0-1 willingness to take risks in combat or unknown rooms (driven by Intelligence)
  889. /// </summary>
  890. public float RiskTolerance => riskTolerance;
  891. /// <summary>
  892. /// Whether this agent has spotted the exit room (through corridor peeking or direct entry)
  893. /// </summary>
  894. public bool KnowsExitLocation => knowsExitLocation;
  895. void OnMouseDown()
  896. {
  897. // If this agent is in a group, clicking any member shows the group panel
  898. if (currentGroup != null)
  899. AgentInfoPanel.ShowGroupInfo(currentGroup);
  900. else
  901. AgentInfoPanel.ShowAgentInfo(this);
  902. }
  903. public void SetShowPath(bool show)
  904. {
  905. showPath = show;
  906. if (pathRenderer != null)
  907. {
  908. pathRenderer.enabled = show;
  909. }
  910. }
  911. public bool GetShowPath() => showPath;
  912. // ------------------------------------------------------------------ //
  913. // Group API //
  914. // ------------------------------------------------------------------ //
  915. /// <summary>Called by AgentGroup when this agent is added.</summary>
  916. public void JoinGroup(AgentGroup group)
  917. {
  918. currentGroup = group;
  919. isGroupFollower = group.Leader != this;
  920. Debug.Log($"[Agent {agentId}] {agentName} joined group {group.GroupId} as {(isGroupFollower ? "follower" : "leader")}");
  921. }
  922. /// <summary>Called by AgentGroup when this agent leaves or group dissolves.</summary>
  923. public void LeaveGroup()
  924. {
  925. currentGroup = null;
  926. isGroupFollower = false;
  927. // Restore own renderer
  928. var mr = GetComponent<MeshRenderer>();
  929. if (mr != null) mr.enabled = true;
  930. var lr = GetComponent<LineRenderer>();
  931. if (lr != null) lr.enabled = showPath;
  932. }
  933. /// <summary>The group this agent belongs to, or null if solo.</summary>
  934. public AgentGroup Group => currentGroup;
  935. /// <summary>True if another agent is driving this agent's position.</summary>
  936. public bool IsGroupFollower => isGroupFollower;
  937. /// <summary>True if this agent leads a group (is not a follower but group exists).</summary>
  938. public bool IsGroupLeader => currentGroup != null && !isGroupFollower;
  939. }