AIAgent.cs 48 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295
  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. [Tooltip("Disable for large agent counts to avoid LineRenderer overhead.")]
  23. [SerializeField] private bool showPath = false;
  24. [SerializeField] private LineRenderer pathRenderer;
  25. [SerializeField] private Color pathColor = Color.yellow;
  26. private MazeController mazeController;
  27. private MazeData maze;
  28. private MazePathfinder pathfinder;
  29. private AIRoomMemory roomMemory;
  30. private List<Vector2Int> currentPath = new();
  31. private int currentPathIndex = 0;
  32. private float lastPathUpdate = 0f;
  33. private Vector2Int currentRoom = Vector2Int.zero;
  34. private Vector2Int targetRoom = Vector2Int.zero;
  35. private Vector2Int targetExitTile = Vector2Int.zero; // Target hallway exit to move toward
  36. private Queue<int> recentRooms = new Queue<int>(); // Track last N rooms to avoid backtracking
  37. private const int RECENT_ROOMS_BUFFER_SIZE = 10; // Larger buffer = less backtracking
  38. private MazeRoom lastRoomExitedFrom = null; // Track which room we exited to prevent immediate backtracking
  39. private bool hasReachedGoal = false; // Track if agent has reached the goal room
  40. private bool commitedToExit = false; // Track if agent has committed to a specific hallway exit
  41. private float nextRandomWait = 0f; // Random wait time before exploring new areas
  42. private float agentRandomOffset = 0f; // Per-agent random offset to desync movement
  43. private bool pathRequestPending = false; // True while waiting for async pathfinding result
  44. // Intelligence-driven disposition (seeded at spawn from Intelligence stat)
  45. // Will drive group-up logic, risk assessment, and future combat decisions
  46. private float groupUpAffinity; // 0-1: likelihood to seek allies over going solo
  47. private float riskTolerance; // 0-1: willingness to enter dangerous/unknown situations
  48. private bool knowsExitLocation = false; // True once agent has "seen" the exit room
  49. // ----- Group membership -----
  50. private AgentGroup currentGroup = null;
  51. private bool isGroupFollower = false; // True when this agent defers movement to the leader
  52. // ----- Combat -----
  53. private Weapon weapon;
  54. private float lastAttackTime = 0f;
  55. private const float ATTACK_COOLDOWN = 1.0f;
  56. /// <summary>Agent advantage multiplier on hit rolls (can improve with experience/weapons later).</summary>
  57. private const float AGENT_HIT_ADVANTAGE = 1.3f;
  58. private bool isDead = false;
  59. /// <summary>True while a monster is within melee range – halts path-following.</summary>
  60. private bool isInCombat = false;
  61. // ----- Room danger / avoidance -----
  62. /// <summary>Room IDs the agent has decided to avoid (too dangerous to fight through).</summary>
  63. private readonly HashSet<int> avoidedRooms = new();
  64. /// <summary>Speed threshold: agents above this fraction of max speed may try to run through.</summary>
  65. private const float RUN_THROUGH_SPEED_FRACTION = 0.6f; // Speed stat > 60 enables sprinting
  66. // ----- Cached maze data (set once in Start, never changes) -----
  67. private MazeRoom[] _allRooms; // Array copy is faster to iterate than List
  68. private HashSet<MazeRoom> _goalRooms; // Constant set for O(1) Contains checks
  69. void Start()
  70. {
  71. mazeController = FindAnyObjectByType<MazeController>();
  72. if (mazeController == null)
  73. {
  74. Debug.LogError("AIAgent: MazeController not found in scene!");
  75. enabled = false;
  76. return;
  77. }
  78. maze = mazeController.GetCurrentMaze();
  79. if (maze == null)
  80. {
  81. Debug.LogError("AIAgent: Current maze is null!");
  82. enabled = false;
  83. return;
  84. }
  85. pathfinder = new MazePathfinder(maze);
  86. roomMemory = AIRoomMemoryManager.GetMemory(agentCharacterType);
  87. // Cache room data once – avoids List allocations and LINQ on every Update
  88. _allRooms = maze.Rooms.ToArray();
  89. _goalRooms = new HashSet<MazeRoom>(maze.GetRoomsByType(MazeRoom.RoomType.End));
  90. // Initialize personalization
  91. agentName = AgentNameGenerator.GenerateRandomName();
  92. agentStats = new AgentStats();
  93. gameObject.name = $"Agent_{agentId} ({agentName})";
  94. // Seed intelligence-driven disposition from stats
  95. groupUpAffinity = agentStats.GroupUpAffinity;
  96. riskTolerance = agentStats.RiskTolerance;
  97. // Equip fists as default weapon
  98. weapon = Weapon.Fists();
  99. // Initialize with a random start point
  100. if (maze.StartPoints.Count > 0)
  101. {
  102. int startIndex = agentId % maze.StartPoints.Count; // Distribute agents across start points
  103. Vector2Int startPos = maze.StartPoints[startIndex];
  104. // Get the start room and spawn randomly within it
  105. MazeRoom startRoom = maze.GetRoomAtTile(startPos.x, startPos.y);
  106. if (startRoom != null)
  107. {
  108. // Spawn at random position within start room
  109. Vector2Int randomPosInRoom = new Vector2Int(
  110. Random.Range(startRoom.MinX + 1, startRoom.MaxX),
  111. Random.Range(startRoom.MinY + 1, startRoom.MaxY)
  112. );
  113. transform.position = new Vector3(randomPosInRoom.x + 0.5f, 1f, randomPosInRoom.y + 0.5f);
  114. }
  115. else
  116. {
  117. // Fallback to exact start point
  118. transform.position = new Vector3(startPos.x + 0.5f, 1f, startPos.y + 0.5f);
  119. }
  120. // Rotate to be visible from above - 90 degrees around X axis
  121. transform.rotation = Quaternion.Euler(90, 0, 0);
  122. UpdateCurrentRoom(); // This will add to recentRooms and visit room
  123. }
  124. else
  125. {
  126. Debug.LogError("AIAgent: No start points found in maze!");
  127. enabled = false;
  128. return;
  129. }
  130. // Setup per-agent random offset to desync decision timings (prevents all agents moving in sync)
  131. agentRandomOffset = Random.Range(0f, pathUpdateInterval * 0.5f);
  132. nextRandomWait = Time.time + Random.Range(2f, 4f); // Random initial wait before first decision
  133. // Scale movement speed by the agent's Speed stat:
  134. // Speed=1 → ~1× base, Speed=100 → 2× base, plus ±10% desync jitter
  135. float speedMultiplier = 1f + (agentStats.Speed / 100f);
  136. actualMovementSpeed = movementSpeed * speedMultiplier * Random.Range(0.9f, 1.1f);
  137. // Suppress per-agent spawn log for large counts; enable only when debugging individuals
  138. // Debug.Log($"[Agent {agentId}] {agentName} | {agentStats} | GroupUp: {groupUpAffinity:P0} | Risk: {riskTolerance:P0}");
  139. // Setup path renderer
  140. if (showPath && pathRenderer == null)
  141. {
  142. pathRenderer = gameObject.AddComponent<LineRenderer>();
  143. pathRenderer.startWidth = 0.1f;
  144. pathRenderer.endWidth = 0.1f;
  145. pathRenderer.material = new Material(Shader.Find("Sprites/Default"));
  146. pathRenderer.startColor = pathColor;
  147. pathRenderer.endColor = pathColor;
  148. }
  149. }
  150. void Update()
  151. {
  152. if (maze == null || isDead) return;
  153. // Followers defer all movement to the group leader
  154. if (isGroupFollower)
  155. {
  156. if (currentGroup != null && currentGroup.Leader != null)
  157. transform.position = currentGroup.Leader.transform.position;
  158. // Followers still fight monsters in range
  159. CombatUpdate();
  160. return;
  161. }
  162. // Fight any adjacent monsters
  163. CombatUpdate();
  164. // Update current room
  165. UpdateCurrentRoom();
  166. // Update pathfinding periodically – skip if an async request is already in-flight
  167. if (!pathRequestPending && Time.time - lastPathUpdate > pathUpdateInterval + agentRandomOffset)
  168. {
  169. UpdatePathToGoal();
  170. lastPathUpdate = Time.time;
  171. }
  172. // Move along path – suppressed while fighting
  173. if (!isInCombat)
  174. FollowPath();
  175. // Debug visualization – only update for agents near the camera to save draw calls
  176. if (showPath && pathRenderer != null && IsNearCamera(80f))
  177. {
  178. UpdatePathVisualization();
  179. }
  180. }
  181. private static Camera _mainCam;
  182. private static float _lastCamFetch;
  183. /// <summary>Returns true if this agent is within <paramref name="sqrDist"/> world units of the main camera.</summary>
  184. private bool IsNearCamera(float sqrDist)
  185. {
  186. if (_mainCam == null || Time.time - _lastCamFetch > 2f)
  187. {
  188. _mainCam = Camera.main;
  189. _lastCamFetch = Time.time;
  190. }
  191. if (_mainCam == null) return true;
  192. return (transform.position - _mainCam.transform.position).sqrMagnitude < sqrDist * sqrDist;
  193. }
  194. private Vector2Int _lastRoomCheckTile = new Vector2Int(-9999, -9999);
  195. /// <summary>
  196. /// Updates the current room based on position.
  197. /// Only queries the maze when the agent has moved to a different tile.
  198. /// </summary>
  199. private void UpdateCurrentRoom()
  200. {
  201. Vector2Int tilePos = WorldToTile(transform.position);
  202. // Skip if we're still on the same tile – saves GetRoomAtTile call every frame
  203. if (tilePos == _lastRoomCheckTile) return;
  204. _lastRoomCheckTile = tilePos;
  205. MazeRoom room = maze.GetRoomAtTile(tilePos.x, tilePos.y);
  206. if (room != null)
  207. {
  208. if (currentRoom.x != room.Id)
  209. {
  210. currentRoom = new Vector2Int(room.Id, 0);
  211. roomMemory.VisitRoom(room.Id);
  212. // Track recent rooms to avoid immediate backtracking
  213. recentRooms.Enqueue(room.Id);
  214. if (recentRooms.Count > RECENT_ROOMS_BUFFER_SIZE)
  215. recentRooms.Dequeue();
  216. }
  217. }
  218. }
  219. /// <summary>
  220. /// Updates pathfinding to reach the goal
  221. /// Agent only knows about current room and visited rooms
  222. /// NEW LOGIC: Pick a hallway exit from current room and move straight to it
  223. /// </summary>
  224. private void UpdatePathToGoal()
  225. {
  226. if (maze.ExitPoints.Count == 0)
  227. {
  228. Debug.LogWarning($"AIAgent {agentId}: No exit points in maze!");
  229. return;
  230. }
  231. Vector2Int currentPos = WorldToTile(transform.position);
  232. MazeRoom currentRoomData = maze.GetRoomAtTile(currentPos.x, currentPos.y);
  233. // CHECK FOR GOAL ROOM FIRST - this should work even while following path
  234. if (currentRoomData != null && _goalRooms.Contains(currentRoomData))
  235. {
  236. if (!hasReachedGoal)
  237. {
  238. hasReachedGoal = true;
  239. currentPath.Clear();
  240. currentPathIndex = 0;
  241. commitedToExit = false;
  242. // If this agent is in a group, mark all members as having reached the goal
  243. if (currentGroup != null)
  244. {
  245. foreach (var member in currentGroup.Members)
  246. {
  247. if (member != null && !member.HasReachedGoal)
  248. {
  249. member.hasReachedGoal = true;
  250. }
  251. }
  252. }
  253. }
  254. return; // Stay stopped
  255. }
  256. // If we already have a valid path and we're following it, don't recalculate (stay committed)
  257. if (currentPath.Count > 0 && currentPathIndex < currentPath.Count)
  258. {
  259. return; // Keep following existing path
  260. }
  261. // If we've committed to reaching an exit and haven't reached it yet, keep trying.
  262. // Guard with pathRequestPending to avoid writing currentPath from two places.
  263. if (commitedToExit && targetExitTile != Vector2Int.zero && !pathRequestPending)
  264. {
  265. if (currentPath.Count == 0 && currentRoomData != null)
  266. {
  267. pathRequestPending = true;
  268. var capturedExit = targetExitTile;
  269. var capturedRoom = currentRoomData;
  270. if (PathfindingScheduler.Instance != null)
  271. {
  272. PathfindingScheduler.Instance.RequestPath(new PathRequest
  273. {
  274. AgentId = agentId,
  275. Start = currentPos,
  276. Goal = capturedExit,
  277. RoomContext = capturedRoom,
  278. IsHallwayMode = false,
  279. Callback = result =>
  280. {
  281. pathRequestPending = false;
  282. if (result.Count > 0)
  283. {
  284. currentPath = result;
  285. currentPathIndex = 0;
  286. }
  287. }
  288. });
  289. }
  290. else
  291. {
  292. currentPath = FindPathInRoom(currentPos, capturedExit, capturedRoom);
  293. currentPathIndex = 0;
  294. pathRequestPending = false;
  295. }
  296. return;
  297. }
  298. }
  299. // If in hallway (no room), just keep moving along current path
  300. // The hallway pathfinding will naturally move us toward exits
  301. if (currentRoomData == null)
  302. {
  303. // If we have a current path, keep following it through hallway
  304. if (currentPath.Count > 0 && currentPathIndex < currentPath.Count)
  305. {
  306. return; // Keep following path
  307. }
  308. // In hallway with no path - find next room to enter
  309. // PRIORITY 1: Find unvisited rooms, avoid the room we just exited
  310. // Collect all unvisited rooms
  311. List<MazeRoom> unvisitedRooms = new();
  312. foreach (var room in _allRooms)
  313. {
  314. // Skip the room we just exited (backtracking prevention)
  315. if (lastRoomExitedFrom != null && room.Id == lastRoomExitedFrom.Id)
  316. continue;
  317. // Prioritize unvisited rooms
  318. if (!roomMemory.HasVisited(room.Id))
  319. {
  320. unvisitedRooms.Add(room);
  321. }
  322. }
  323. MazeRoom targetRoom = null;
  324. // Random decision: 70% closest unvisited, 30% random unvisited (increases exploration variety)
  325. if (unvisitedRooms.Count > 0)
  326. {
  327. if (Random.value < 0.7f && unvisitedRooms.Count > 0)
  328. {
  329. // Pick closest unvisited room
  330. float closestUnvisitedDistance = float.MaxValue;
  331. foreach (var room in unvisitedRooms)
  332. {
  333. Vector2Int roomCenter = room.GetCenter();
  334. float distance = Vector2Int.Distance(currentPos, roomCenter);
  335. if (distance < closestUnvisitedDistance)
  336. {
  337. closestUnvisitedDistance = distance;
  338. targetRoom = room;
  339. }
  340. }
  341. }
  342. else
  343. {
  344. // Pick random unvisited room for variety
  345. targetRoom = unvisitedRooms[Random.Range(0, unvisitedRooms.Count)];
  346. }
  347. }
  348. // If no unvisited room found, pick nearest room (except the one we came from)
  349. if (targetRoom == null)
  350. {
  351. float closestDistance = float.MaxValue;
  352. foreach (var room in _allRooms)
  353. {
  354. // Skip the room we just exited
  355. if (lastRoomExitedFrom != null && room.Id == lastRoomExitedFrom.Id)
  356. continue;
  357. Vector2Int roomCenter = room.GetCenter();
  358. float distance = Vector2Int.Distance(currentPos, roomCenter);
  359. if (distance < closestDistance)
  360. {
  361. closestDistance = distance;
  362. targetRoom = room;
  363. }
  364. }
  365. }
  366. // Exit-room caution: if the chosen target is the exit room and we spotted
  367. // it through a short corridor, smarter agents may hesitate or route around.
  368. // (Low intelligence agents rush straight in; high intelligence agents are cautious
  369. // and may wait for allies — once group logic is implemented.)
  370. if (targetRoom != null)
  371. {
  372. if (_goalRooms.Contains(targetRoom))
  373. {
  374. knowsExitLocation = true;
  375. // High-intelligence agents (INT > 50) pause briefly to "assess"
  376. // before committing — placeholder for future ally/fight evaluation
  377. if (agentStats.Intelligence > 50)
  378. {
  379. nextRandomWait = Time.time + (agentStats.Intelligence / 100f) * 2f;
  380. }
  381. }
  382. }
  383. if (targetRoom != null)
  384. {
  385. // Request async path – result arrives next frame via callback
  386. if (PathfindingScheduler.Instance != null)
  387. {
  388. pathRequestPending = true;
  389. var capturedRoom = targetRoom;
  390. PathfindingScheduler.Instance.RequestPath(new PathRequest
  391. {
  392. AgentId = agentId,
  393. Start = currentPos,
  394. Goal = capturedRoom.GetCenter(),
  395. IsHallwayMode = true,
  396. Callback = result =>
  397. {
  398. pathRequestPending = false;
  399. if (result.Count > 0)
  400. {
  401. currentPath = result;
  402. currentPathIndex = 0;
  403. }
  404. // else: fallback handled next update cycle
  405. }
  406. });
  407. }
  408. else
  409. {
  410. // Fallback: synchronous (scheduler not ready yet)
  411. currentPath = FindPathToNearestRoom(currentPos, targetRoom);
  412. currentPathIndex = 0;
  413. }
  414. return;
  415. }
  416. Debug.LogWarning($"AIAgent {agentId}: In hallway but no reachable rooms!");
  417. return;
  418. }
  419. // MAIN LOGIC: In regular room, find a hallway exit and path to it
  420. // Get all hallway tiles adjacent to this room
  421. List<Vector2Int> hallwayExits = FindHallwayExits(currentRoomData, currentPos);
  422. if (hallwayExits.Count > 0)
  423. {
  424. // Peek through each exit: if the corridor leads directly to the exit room
  425. // or a dangerous room, apply intelligence/danger avoidance logic.
  426. List<Vector2Int> safeExits = new List<Vector2Int>(hallwayExits);
  427. foreach (var exit in hallwayExits)
  428. {
  429. MazeRoom peekedRoom = PeekCorridorDestination(exit, currentRoomData);
  430. if (peekedRoom == null) continue;
  431. // Check if exit leads to the goal room
  432. if (!knowsExitLocation && _goalRooms.Contains(peekedRoom))
  433. {
  434. knowsExitLocation = true;
  435. if (agentStats.Intelligence > 40 && safeExits.Count > 1)
  436. safeExits.Remove(exit);
  437. }
  438. // Check if exit leads to a monster-heavy room
  439. if (ShouldAvoidRoom(peekedRoom) && safeExits.Count > 1)
  440. {
  441. avoidedRooms.Add(peekedRoom.Id);
  442. safeExits.Remove(exit);
  443. Debug.Log($"[Agent {agentId}] {agentName}: avoiding room {peekedRoom.Id} (threat {MonsterSpawner.GetRoomThreat(peekedRoom.Id)})");
  444. }
  445. }
  446. if (safeExits.Count == 0) safeExits = hallwayExits; // No alternative – must go through
  447. // Pick a random hallway exit from the (possibly filtered) list
  448. Vector2Int chosenExit = safeExits[Random.Range(0, safeExits.Count)];
  449. // Request path asynchronously via scheduler
  450. if (PathfindingScheduler.Instance != null)
  451. {
  452. pathRequestPending = true;
  453. var capturedExit = chosenExit;
  454. var capturedRoom = currentRoomData;
  455. PathfindingScheduler.Instance.RequestPath(new PathRequest
  456. {
  457. AgentId = agentId,
  458. Start = currentPos,
  459. Goal = capturedExit,
  460. RoomContext = capturedRoom,
  461. IsHallwayMode = false,
  462. Callback = result =>
  463. {
  464. pathRequestPending = false;
  465. if (result.Count > 0)
  466. {
  467. currentPath = result;
  468. currentPathIndex = 0;
  469. targetExitTile = capturedExit;
  470. commitedToExit = true;
  471. nextRandomWait = Time.time + Random.Range(1.5f, 3.5f);
  472. }
  473. else
  474. {
  475. commitedToExit = false;
  476. }
  477. }
  478. });
  479. }
  480. else
  481. {
  482. // Fallback: synchronous
  483. currentPath = FindPathInRoom(currentPos, chosenExit, currentRoomData);
  484. currentPathIndex = 0;
  485. if (currentPath.Count > 0)
  486. {
  487. targetExitTile = chosenExit;
  488. commitedToExit = true;
  489. nextRandomWait = Time.time + Random.Range(1.5f, 3.5f);
  490. }
  491. else
  492. {
  493. commitedToExit = false;
  494. }
  495. }
  496. }
  497. else
  498. {
  499. Debug.LogWarning($"AIAgent {agentId}: No hallway exits found from room {currentRoomData.Id}");
  500. commitedToExit = false;
  501. }
  502. }
  503. /// <summary>
  504. /// Chooses the next room to move to
  505. /// Searches around the current room to find adjacent rooms
  506. /// Avoids recently visited rooms to prevent backtracking
  507. /// </summary>
  508. private MazeRoom ChooseNextRoom(MazeRoom currentRoom)
  509. {
  510. // Find all adjacent rooms by checking walkable tiles in all directions from room boundaries
  511. List<MazeRoom> connectedRooms = new();
  512. Vector2Int roomCenter = currentRoom.GetCenter();
  513. // Check from each direction outward from the room center
  514. Vector2Int[] directions = new Vector2Int[]
  515. {
  516. Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right,
  517. Vector2Int.up + Vector2Int.left, Vector2Int.up + Vector2Int.right,
  518. Vector2Int.down + Vector2Int.left, Vector2Int.down + Vector2Int.right
  519. };
  520. // Shuffle directions for randomness
  521. for (int i = directions.Length - 1; i > 0; i--)
  522. {
  523. int randomIndex = Random.Range(0, i + 1);
  524. var temp = directions[i];
  525. directions[i] = directions[randomIndex];
  526. directions[randomIndex] = temp;
  527. }
  528. // Try each direction and look for tiles that lead to other rooms
  529. foreach (var dir in directions)
  530. {
  531. for (int dist = 1; dist < 20; dist++) // Search up to 20 tiles away
  532. {
  533. Vector2Int testPos = roomCenter + (dir * dist);
  534. if (!maze.IsInBounds(testPos.x, testPos.y) || !maze.IsWalkable(testPos.x, testPos.y))
  535. continue;
  536. MazeRoom testRoom = maze.GetRoomAtTile(testPos.x, testPos.y);
  537. if (testRoom != null && testRoom.Id != currentRoom.Id && !connectedRooms.Contains(testRoom))
  538. {
  539. connectedRooms.Add(testRoom);
  540. break; // Found a room in this direction, move to next direction
  541. }
  542. }
  543. }
  544. if (connectedRooms.Count == 0)
  545. {
  546. Debug.LogWarning($"AIAgent {agentId}: No adjacent rooms found from room {currentRoom.Id}");
  547. return null;
  548. }
  549. // Filter out recently visited rooms (to avoid backtracking)
  550. List<MazeRoom> nonRecentRooms = connectedRooms.Where(r => !recentRooms.Contains(r.Id)).ToList();
  551. // PRIORITY 1: Strongly prefer completely unvisited rooms (not recently visited either)
  552. var completelyUnvisited = nonRecentRooms.Where(r => !roomMemory.HasVisited(r.Id)).ToList();
  553. if (completelyUnvisited.Count > 0)
  554. {
  555. MazeRoom chosen = completelyUnvisited[Random.Range(0, completelyUnvisited.Count)];
  556. return chosen;
  557. }
  558. // PRIORITY 2: Try non-recent rooms even if visited by this character type
  559. if (nonRecentRooms.Count > 0)
  560. {
  561. // Among non-recent rooms, prefer unvisited by this agent's character type
  562. var unvisitedByType = nonRecentRooms.Where(r => !roomMemory.HasVisited(r.Id)).ToList();
  563. if (unvisitedByType.Count > 0)
  564. {
  565. MazeRoom chosen = unvisitedByType[Random.Range(0, unvisitedByType.Count)];
  566. return chosen;
  567. }
  568. // Otherwise just pick a random non-recent room
  569. MazeRoom chosen2 = nonRecentRooms[Random.Range(0, nonRecentRooms.Count)];
  570. return chosen2;
  571. }
  572. // PRIORITY 3: If all rooms are recent, pick one that's been visited least recently
  573. // Find the room in connectedRooms that was added to recentRooms earliest (will be dequeued first)
  574. MazeRoom leastRecentRoom = connectedRooms[0];
  575. foreach (var room in connectedRooms)
  576. {
  577. if (!roomMemory.HasVisited(room.Id))
  578. {
  579. leastRecentRoom = room;
  580. break;
  581. }
  582. }
  583. Debug.Log($"AIAgent {agentId}: All rooms recent, choosing least-recent room {leastRecentRoom.Id}");
  584. return leastRecentRoom;
  585. }
  586. /// <summary>
  587. /// Finds the nearest room from a position (used when in hallway)
  588. /// </summary>
  589. private MazeRoom FindNearestRoom(Vector2Int position)
  590. {
  591. MazeRoom nearest = null;
  592. float nearestDistance = float.MaxValue;
  593. foreach (var room in maze.Rooms)
  594. {
  595. Vector2Int roomCenter = room.GetCenter();
  596. float distance = Vector2Int.Distance(position, roomCenter);
  597. if (distance < nearestDistance)
  598. {
  599. nearestDistance = distance;
  600. nearest = room;
  601. }
  602. }
  603. return nearest;
  604. }
  605. /// <summary>
  606. /// Finds all hallway exit tiles adjacent to a room
  607. /// These are walkable tiles just outside the room boundary
  608. /// </summary>
  609. /// <summary>
  610. /// Peeks along a corridor starting from a hallway exit tile.
  611. /// Follows walkable non-room tiles up to a short distance and returns
  612. /// the first room found at the other end, or null if no room is close.
  613. /// Used by intelligence logic to detect if an exit leads straight to the goal.
  614. /// </summary>
  615. private MazeRoom PeekCorridorDestination(Vector2Int exitTile, MazeRoom sourceRoom, int maxPeekDistance = 12)
  616. {
  617. // BFS outward from the exit tile, staying in non-room (hallway) tiles
  618. var visited = new HashSet<Vector2Int> { exitTile };
  619. var queue = new Queue<Vector2Int>();
  620. queue.Enqueue(exitTile);
  621. while (queue.Count > 0)
  622. {
  623. Vector2Int tile = queue.Dequeue();
  624. Vector2Int[] dirs = {
  625. new(tile.x + 1, tile.y), new(tile.x - 1, tile.y),
  626. new(tile.x, tile.y + 1), new(tile.x, tile.y - 1)
  627. };
  628. foreach (var next in dirs)
  629. {
  630. if (!maze.IsInBounds(next.x, next.y) || !maze.IsWalkable(next.x, next.y)) continue;
  631. if (visited.Contains(next)) continue;
  632. MazeRoom nextRoom = maze.GetRoomAtTile(next.x, next.y);
  633. if (nextRoom != null && nextRoom.Id != sourceRoom.Id)
  634. return nextRoom; // Found the room at the end of this corridor
  635. // Only continue through hallway tiles and within peek distance
  636. if (nextRoom == null && Vector2Int.Distance(exitTile, next) < maxPeekDistance)
  637. {
  638. visited.Add(next);
  639. queue.Enqueue(next);
  640. }
  641. }
  642. }
  643. return null;
  644. }
  645. private List<Vector2Int> FindHallwayExits(MazeRoom room, Vector2Int currentPos)
  646. {
  647. List<Vector2Int> exits = new();
  648. HashSet<Vector2Int> addedExits = new();
  649. // Check all tiles on the boundary of the room
  650. // North boundary
  651. for (int x = room.MinX; x <= room.MaxX; x++)
  652. {
  653. int y = room.MinY - 1;
  654. if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y)))
  655. {
  656. exits.Add(new Vector2Int(x, y));
  657. addedExits.Add(new Vector2Int(x, y));
  658. }
  659. }
  660. // South boundary
  661. for (int x = room.MinX; x <= room.MaxX; x++)
  662. {
  663. int y = room.MaxY + 1;
  664. if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y)))
  665. {
  666. exits.Add(new Vector2Int(x, y));
  667. addedExits.Add(new Vector2Int(x, y));
  668. }
  669. }
  670. // West boundary
  671. for (int y = room.MinY; y <= room.MaxY; y++)
  672. {
  673. int x = room.MinX - 1;
  674. if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y)))
  675. {
  676. exits.Add(new Vector2Int(x, y));
  677. addedExits.Add(new Vector2Int(x, y));
  678. }
  679. }
  680. // East boundary
  681. for (int y = room.MinY; y <= room.MaxY; y++)
  682. {
  683. int x = room.MaxX + 1;
  684. if (maze.IsInBounds(x, y) && maze.IsWalkable(x, y) && !addedExits.Contains(new Vector2Int(x, y)))
  685. {
  686. exits.Add(new Vector2Int(x, y));
  687. addedExits.Add(new Vector2Int(x, y));
  688. }
  689. }
  690. return exits;
  691. }
  692. /// <summary>
  693. /// Finds a path from current position to the nearest room (when in hallway).
  694. /// Uses a min-heap open set for O(n log n) instead of O(n²).
  695. /// </summary>
  696. private List<Vector2Int> FindPathToNearestRoom(Vector2Int start, MazeRoom targetRoom)
  697. {
  698. var openSet = new MinHeap<Vector2Int>();
  699. var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
  700. var gScore = new Dictionary<Vector2Int, float> { [start] = 0f };
  701. var targetCenter = targetRoom.GetCenter();
  702. openSet.Enqueue(start, Heuristic(start, targetCenter));
  703. const int maxIterations = 2000;
  704. int iterations = 0;
  705. while (openSet.Count > 0 && iterations++ < maxIterations)
  706. {
  707. Vector2Int current = openSet.Dequeue();
  708. // Reached the target room
  709. MazeRoom roomAtCurrent = maze.GetRoomAtTile(current.x, current.y);
  710. if (roomAtCurrent != null && roomAtCurrent.Id == targetRoom.Id)
  711. return ReconstructPath(cameFrom, current);
  712. Vector2Int[] dirs = {
  713. new(current.x + 1, current.y), new(current.x - 1, current.y),
  714. new(current.x, current.y + 1), new(current.x, current.y - 1)
  715. };
  716. foreach (var nb in dirs)
  717. {
  718. if (!maze.IsInBounds(nb.x, nb.y) || !maze.IsWalkable(nb.x, nb.y)) continue;
  719. float tentativeG = gScore[current] + 1f;
  720. if (!gScore.TryGetValue(nb, out float existingG) || tentativeG < existingG)
  721. {
  722. cameFrom[nb] = current;
  723. gScore[nb] = tentativeG;
  724. float f = tentativeG + Heuristic(nb, targetCenter);
  725. if (openSet.Contains(nb, out _)) openSet.UpdatePriority(nb, f);
  726. else openSet.Enqueue(nb, f);
  727. }
  728. }
  729. }
  730. return new List<Vector2Int>();
  731. }
  732. private static float Heuristic(Vector2Int a, Vector2Int b)
  733. => Mathf.Abs(a.x - b.x) + Mathf.Abs(a.y - b.y);
  734. /// <summary>
  735. /// Finds a boundary tile of the current room that points toward the next room
  736. /// </summary>
  737. private Vector2Int FindBoundaryTileToward(MazeRoom currentRoom, MazeRoom nextRoom, Vector2Int currentPos)
  738. {
  739. Vector2Int nextRoomCenter = nextRoom.GetCenter();
  740. Vector2Int closestBoundary = currentRoom.GetCenter();
  741. float closestDistance = float.MaxValue;
  742. // Check all boundary tiles of current room
  743. for (int x = currentRoom.MinX; x <= currentRoom.MaxX; x++)
  744. {
  745. for (int y = currentRoom.MinY; y <= currentRoom.MaxY; y++)
  746. {
  747. // Only check boundary and walkable tiles
  748. if ((x == currentRoom.MinX || x == currentRoom.MaxX || y == currentRoom.MinY || y == currentRoom.MaxY) &&
  749. maze.IsWalkable(x, y))
  750. {
  751. // Find boundary tile closest to next room center
  752. float distToNext = Vector2Int.Distance(new Vector2Int(x, y), nextRoomCenter);
  753. if (distToNext < closestDistance)
  754. {
  755. closestDistance = distToNext;
  756. closestBoundary = new Vector2Int(x, y);
  757. }
  758. }
  759. }
  760. }
  761. return closestBoundary;
  762. }
  763. /// <summary>
  764. /// Finds a path within a single room (limited knowledge).
  765. /// Uses a min-heap open set for O(n log n) performance instead of O(n²).
  766. /// </summary>
  767. private List<Vector2Int> FindPathInRoom(Vector2Int start, Vector2Int goal, MazeRoom room)
  768. {
  769. var openSet = new MinHeap<Vector2Int>();
  770. var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
  771. var gScore = new Dictionary<Vector2Int, float> { [start] = 0f };
  772. openSet.Enqueue(start, Heuristic(start, goal));
  773. const int maxIterations = 1000;
  774. int iterations = 0;
  775. while (openSet.Count > 0 && iterations++ < maxIterations)
  776. {
  777. Vector2Int current = openSet.Dequeue();
  778. if (current == goal)
  779. return ReconstructPath(cameFrom, current);
  780. foreach (var nb in GetRoomNeighbors(current, room))
  781. {
  782. float tentativeG = gScore[current] + 1f;
  783. if (!gScore.TryGetValue(nb, out float existingG) || tentativeG < existingG)
  784. {
  785. cameFrom[nb] = current;
  786. gScore[nb] = tentativeG;
  787. float f = tentativeG + Heuristic(nb, goal);
  788. if (openSet.Contains(nb, out _)) openSet.UpdatePriority(nb, f);
  789. else openSet.Enqueue(nb, f);
  790. }
  791. }
  792. }
  793. return new List<Vector2Int>();
  794. }
  795. /// <summary>
  796. /// Gets walkable neighbors within a room or at room boundary
  797. /// Allows pathfinding to reach hallway exits outside room
  798. /// </summary>
  799. private List<Vector2Int> GetRoomNeighbors(Vector2Int position, MazeRoom room)
  800. {
  801. var neighbors = new List<Vector2Int>();
  802. Vector2Int[] directions = new[]
  803. {
  804. new Vector2Int(position.x + 1, position.y),
  805. new Vector2Int(position.x - 1, position.y),
  806. new Vector2Int(position.x, position.y + 1),
  807. new Vector2Int(position.x, position.y - 1),
  808. };
  809. foreach (var dir in directions)
  810. {
  811. // Allow tiles within room OR immediately adjacent to room (boundary)
  812. bool inBounds = maze.IsInBounds(dir.x, dir.y);
  813. bool isWalkable = maze.IsWalkable(dir.x, dir.y);
  814. bool inRoom = room.Contains(dir.x, dir.y);
  815. bool nearBoundary = (dir.x == room.MinX - 1 || dir.x == room.MaxX + 1 ||
  816. dir.y == room.MinY - 1 || dir.y == room.MaxY + 1);
  817. if (inBounds && isWalkable && (inRoom || nearBoundary))
  818. {
  819. neighbors.Add(dir);
  820. }
  821. }
  822. return neighbors;
  823. }
  824. /// <summary>
  825. /// Reconstructs path from A* results.
  826. /// Uses Add+Reverse (O(n)) instead of Insert(0,...) (O(n²)).
  827. /// </summary>
  828. private static List<Vector2Int> ReconstructPath(Dictionary<Vector2Int, Vector2Int> cameFrom, Vector2Int current)
  829. {
  830. var path = new List<Vector2Int>();
  831. while (cameFrom.TryGetValue(current, out var prev))
  832. {
  833. path.Add(current);
  834. current = prev;
  835. }
  836. path.Add(current); // start node
  837. path.Reverse();
  838. return path;
  839. }
  840. /// <summary>
  841. /// Follows the current path
  842. /// </summary>
  843. private void FollowPath()
  844. {
  845. if (currentPath.Count == 0) return;
  846. Vector2Int currentTarget = currentPath[currentPathIndex];
  847. // Maze coordinates: X,Y → World: X,Z (Y=0 is ground level)
  848. // Keep Y at 1f to stay above the maze floor
  849. Vector3 targetWorldPos = new Vector3(currentTarget.x + 0.5f, 1f, currentTarget.y + 0.5f);
  850. Vector3 direction = (targetWorldPos - transform.position).normalized;
  851. // Move directly instead of using Translate (no rigidbody)
  852. transform.position += direction * actualMovementSpeed * Time.deltaTime;
  853. // Check if we've exited the room (entered hallway)
  854. Vector2Int currentPos = WorldToTile(transform.position);
  855. MazeRoom roomAtPos = maze.GetRoomAtTile(currentPos.x, currentPos.y);
  856. if (roomAtPos == null && currentRoom.x != -1)
  857. {
  858. // We've entered a hallway - track which room we came from to prevent immediate backtracking
  859. lastRoomExitedFrom = maze.GetRoomById(currentRoom.x);
  860. commitedToExit = false; // No longer committed to that exit, now in hallway
  861. targetExitTile = Vector2Int.zero; // Clear the target exit
  862. }
  863. // Move to next waypoint when close enough
  864. if (Vector3.Distance(transform.position, targetWorldPos) < stoppingDistance)
  865. {
  866. currentPathIndex++;
  867. if (currentPathIndex >= currentPath.Count)
  868. {
  869. currentPath.Clear();
  870. }
  871. }
  872. }
  873. /// <summary>
  874. /// Updates the path visualization
  875. /// </summary>
  876. private void UpdatePathVisualization()
  877. {
  878. if (currentPath.Count == 0)
  879. {
  880. pathRenderer.positionCount = 0;
  881. return;
  882. }
  883. var positions = new Vector3[currentPath.Count];
  884. for (int i = 0; i < currentPath.Count; i++)
  885. {
  886. // Maze coordinates: X,Y → World: X,Z (Y=0 is ground level)
  887. // Keep Y at 1f to stay above the maze floor
  888. positions[i] = new Vector3(currentPath[i].x + 0.5f, 1f, currentPath[i].y + 0.5f);
  889. }
  890. pathRenderer.positionCount = positions.Length;
  891. pathRenderer.SetPositions(positions);
  892. }
  893. /// <summary>
  894. /// Converts world position to tile coordinate
  895. /// Maze coordinates: X,Y ← World: X,Z (Y=0 is ground level)
  896. /// </summary>
  897. private Vector2Int WorldToTile(Vector3 worldPos)
  898. {
  899. return new Vector2Int(Mathf.FloorToInt(worldPos.x), Mathf.FloorToInt(worldPos.z));
  900. }
  901. /// <summary>
  902. /// Gets agent ID
  903. /// </summary>
  904. public int AgentId => agentId;
  905. /// <summary>
  906. /// Gets agent character type
  907. /// </summary>
  908. public string CharacterType => agentCharacterType;
  909. /// <summary>
  910. /// Gets current room
  911. /// </summary>
  912. public Vector2Int CurrentRoom => currentRoom;
  913. /// <summary>
  914. /// Gets room memory
  915. /// </summary>
  916. public AIRoomMemory RoomMemory => roomMemory;
  917. /// <summary>
  918. /// Gets agent ID (public accessor for triggers)
  919. /// </summary>
  920. public int GetAgentId() => agentId;
  921. /// <summary>
  922. /// Called by ExitRoomTrigger when agent enters the goal room
  923. /// Immediately stops all movement
  924. /// Also marks all group members as having reached the goal
  925. /// </summary>
  926. public void StopAtGoal()
  927. {
  928. hasReachedGoal = true;
  929. currentPath.Clear();
  930. currentPathIndex = 0;
  931. commitedToExit = false;
  932. // If this agent is in a group, mark all members as having reached the goal
  933. if (currentGroup != null)
  934. {
  935. foreach (var member in currentGroup.Members)
  936. {
  937. if (member != null && !member.HasReachedGoal)
  938. {
  939. member.hasReachedGoal = true;
  940. }
  941. }
  942. }
  943. }
  944. /// <summary>
  945. /// Gets whether this agent has reached the goal
  946. /// </summary>
  947. public bool HasReachedGoal => hasReachedGoal;
  948. /// <summary>True once the agent's health reaches 0.</summary>
  949. public bool IsDead => isDead;
  950. /// <summary>Current health points (mirrors AgentStats).</summary>
  951. public int CurrentHealth => agentStats?.CurrentHealth ?? 0;
  952. // ------------------------------------------------------------------ //
  953. // Combat //
  954. // ------------------------------------------------------------------ //
  955. /// <summary>
  956. /// Called by monsters when they deal damage to this agent.
  957. /// </summary>
  958. public void TakeDamage(int amount, Monster source)
  959. {
  960. if (isDead) return;
  961. agentStats.ApplyDamage(amount);
  962. if (agentStats.IsDead)
  963. Die();
  964. }
  965. private void Die()
  966. {
  967. if (isDead) return;
  968. isDead = true;
  969. currentPath.Clear();
  970. Debug.Log($"[Agent {agentId}] {agentName} has died!");
  971. // Notify the manager so death stats can be tracked
  972. var manager = FindAnyObjectByType<AIAgentManager>();
  973. manager?.RegisterAgentDeath(this);
  974. // Leave group cleanly
  975. currentGroup?.RemoveMember(this);
  976. Destroy(gameObject, 0.1f);
  977. }
  978. /// <summary>
  979. /// Combat tick: find the nearest in-range monster and attack it.
  980. /// Sets isInCombat to halt movement while a monster is close.
  981. /// </summary>
  982. private void CombatUpdate()
  983. {
  984. // Scan for the nearest living monster within melee range
  985. Monster nearestMonster = null;
  986. float bestDist = weapon.MeleeRange * weapon.MeleeRange;
  987. foreach (var m in FindObjectsByType<Monster>())
  988. {
  989. if (m.IsDead) continue;
  990. float sqDist = (m.transform.position - transform.position).sqrMagnitude;
  991. if (sqDist < bestDist)
  992. {
  993. bestDist = sqDist;
  994. nearestMonster = m;
  995. }
  996. }
  997. // Update combat lock – halts FollowPath while a monster is adjacent
  998. isInCombat = nearestMonster != null;
  999. if (nearestMonster == null) return;
  1000. // Attack on cooldown
  1001. if (Time.time - lastAttackTime < ATTACK_COOLDOWN) return;
  1002. lastAttackTime = Time.time;
  1003. // Agents have advantage on hit
  1004. if (weapon.TryHit(AGENT_HIT_ADVANTAGE))
  1005. {
  1006. int dmg = weapon.RollDamage();
  1007. bool killed = nearestMonster.TakeDamage(dmg);
  1008. Debug.Log($"[Agent {agentId}] {agentName} hit monster for {dmg} (monster HP left: {nearestMonster.CurrentHealth})");
  1009. if (killed)
  1010. {
  1011. var manager = FindAnyObjectByType<AIAgentManager>();
  1012. manager?.RegisterMonsterKill();
  1013. }
  1014. }
  1015. else
  1016. {
  1017. Debug.Log($"[Agent {agentId}] {agentName} missed monster");
  1018. }
  1019. }
  1020. // ------------------------------------------------------------------ //
  1021. // Room danger assessment //
  1022. // ------------------------------------------------------------------ //
  1023. /// <summary>
  1024. /// Returns the perceived danger score of a room.
  1025. /// Uses MonsterSpawner's threat lookup (0 = safe, higher = more dangerous).
  1026. /// </summary>
  1027. private int GetRoomThreat(MazeRoom room)
  1028. {
  1029. if (room == null) return 0;
  1030. return MonsterSpawner.GetRoomThreat(room.Id);
  1031. }
  1032. /// <summary>
  1033. /// Decides whether to avoid, run through, or fight through a room.
  1034. /// Called before committing to an exit that leads to a dangerous room.
  1035. /// Returns true if the agent should avoid the target room.
  1036. /// </summary>
  1037. private bool ShouldAvoidRoom(MazeRoom targetRoom)
  1038. {
  1039. if (targetRoom == null) return false;
  1040. int threat = GetRoomThreat(targetRoom);
  1041. if (threat == 0) return false;
  1042. // Estimate own fighting power: health * (Strength / 50)
  1043. float power = agentStats.CurrentHealth * (agentStats.Strength / 50f);
  1044. // Risk tolerance 0=cautious, 1=reckless
  1045. float dangerThreshold = Mathf.Lerp(0.5f, 2.0f, riskTolerance);
  1046. bool tooRisky = threat > power * dangerThreshold;
  1047. if (!tooRisky) return false;
  1048. // Fast agents (Speed > 60) might choose to sprint through instead of avoiding
  1049. if (agentStats.Speed > 60)
  1050. {
  1051. float runChance = (agentStats.Speed - 60f) / 40f; // 0 at spd=60, 1 at spd=100
  1052. if (Random.value < runChance)
  1053. {
  1054. Debug.Log($"[Agent {agentId}] {agentName}: sprinting through dangerous room {targetRoom.Id}!");
  1055. return false; // Will run, not avoid
  1056. }
  1057. }
  1058. return true; // Should avoid
  1059. }
  1060. /// <summary>
  1061. /// Gets agent's personalized name
  1062. /// </summary>
  1063. public string AgentName => agentName;
  1064. /// <summary>
  1065. /// Gets agent's stats
  1066. /// </summary>
  1067. public AgentStats Stats => agentStats;
  1068. /// <summary>
  1069. /// 0-1 tendency to seek allies over going solo (driven by Intelligence)
  1070. /// </summary>
  1071. public float GroupUpAffinity => groupUpAffinity;
  1072. /// <summary>
  1073. /// 0-1 willingness to take risks in combat or unknown rooms (driven by Intelligence)
  1074. /// </summary>
  1075. public float RiskTolerance => riskTolerance;
  1076. /// <summary>
  1077. /// Whether this agent has spotted the exit room (through corridor peeking or direct entry)
  1078. /// </summary>
  1079. public bool KnowsExitLocation => knowsExitLocation;
  1080. void OnMouseDown()
  1081. {
  1082. // If this agent is in a group, clicking any member shows the group panel
  1083. if (currentGroup != null)
  1084. AgentInfoPanel.ShowGroupInfo(currentGroup);
  1085. else
  1086. AgentInfoPanel.ShowAgentInfo(this);
  1087. }
  1088. public void SetShowPath(bool show)
  1089. {
  1090. showPath = show;
  1091. if (pathRenderer != null)
  1092. {
  1093. pathRenderer.enabled = show;
  1094. }
  1095. }
  1096. public bool GetShowPath() => showPath;
  1097. // ------------------------------------------------------------------ //
  1098. // Group API //
  1099. // ------------------------------------------------------------------ //
  1100. /// <summary>Called by AgentGroup when this agent is added.</summary>
  1101. public void JoinGroup(AgentGroup group)
  1102. {
  1103. currentGroup = group;
  1104. isGroupFollower = group.Leader != this;
  1105. Debug.Log($"[Agent {agentId}] {agentName} joined group {group.GroupId} as {(isGroupFollower ? "follower" : "leader")}");
  1106. }
  1107. /// <summary>Called by AgentGroup when this agent leaves or group dissolves.</summary>
  1108. public void LeaveGroup()
  1109. {
  1110. currentGroup = null;
  1111. isGroupFollower = false;
  1112. // Restore own renderer
  1113. var mr = GetComponent<MeshRenderer>();
  1114. if (mr != null) mr.enabled = true;
  1115. var lr = GetComponent<LineRenderer>();
  1116. if (lr != null) lr.enabled = showPath;
  1117. }
  1118. /// <summary>The group this agent belongs to, or null if solo.</summary>
  1119. public AgentGroup Group => currentGroup;
  1120. /// <summary>True if another agent is driving this agent's position.</summary>
  1121. public bool IsGroupFollower => isGroupFollower;
  1122. /// <summary>True if this agent leads a group (is not a follower but group exists).</summary>
  1123. public bool IsGroupLeader => currentGroup != null && !isGroupFollower;
  1124. }