AIAgent.cs 54 KB

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