AutonomousAgentBrain.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using UnityEngine;
  5. namespace MazeGen
  6. {
  7. /// <summary>
  8. /// Drives one agent's autonomous exploration: waits out a (randomized) initial delay so
  9. /// many agents don't all decide in lockstep, then ticks a steady decision interval -
  10. /// each time it fires, picks an exit and asks the MazeAgentController to use it. Stops
  11. /// deciding once the agent reaches an exit room or the level has ended.
  12. ///
  13. /// Agents can "peek" into adjacent rooms before entering to see if there are monsters.
  14. /// Based on the perceived threat level, they may choose to retreat rather than fight.
  15. /// </summary>
  16. public class AutonomousAgentBrain : MonoBehaviour
  17. {
  18. private MazeAgentController _controller;
  19. private MazeGenerator _generator;
  20. private float _decisionInterval;
  21. private float _exploreChance;
  22. private float _retreatThreshold = 1.5f; // CR above this = retreat (configurable)
  23. private Func<bool> _isLevelEnded;
  24. private float _timer;
  25. private Dictionary<int, int> _peekCount = new Dictionary<int, int>(); // Track how many times agent peeked at a room
  26. public void Initialize(MazeAgentController controller, MazeGenerator generator, float initialDelay, float decisionInterval, float exploreChance, float retreatThreshold, Func<bool> isLevelEnded)
  27. {
  28. _controller = controller;
  29. _generator = generator;
  30. _decisionInterval = Mathf.Max(0.05f, decisionInterval);
  31. _exploreChance = Mathf.Clamp01(exploreChance);
  32. _retreatThreshold = Mathf.Max(0, retreatThreshold);
  33. _isLevelEnded = isLevelEnded;
  34. _timer = Mathf.Max(0f, initialDelay);
  35. }
  36. private void Update()
  37. {
  38. if (_controller?.Agent == null || _controller.Agent.IsFinished || _controller.Agent.IsDead) return;
  39. if (_isLevelEnded != null && _isLevelEnded()) return;
  40. _timer -= Time.deltaTime;
  41. if (_timer > 0f) return;
  42. _timer = _decisionInterval;
  43. Exit choice = ChooseExit(_controller.Agent, _controller.Agent.CurrentRoom);
  44. if (choice != null)
  45. _controller.TryUseExit(choice);
  46. }
  47. /// <summary>
  48. /// Picks the next exit to take. Strongly prefers an unused exit (fresh, unexplored
  49. /// territory); failing that, prefers a used exit into a room this agent personally
  50. /// hasn't visited yet (following where another agent has already been); only
  51. /// backtracks into a room it's already visited as a last resort. `exploreChance`
  52. /// adds some randomness so a "follow" opportunity is occasionally taken even when
  53. /// exploring is available, rather than being 100% deterministic.
  54. ///
  55. /// A few anti-stuck rules keep this from looping forever around a small ring of
  56. /// already-explored rooms (e.g. a square formed by a loop-closing connection):
  57. /// - The door leading straight back to the room the agent just came from is excluded
  58. /// from consideration unless it's the only door available at all, so agents don't
  59. /// ping-pong between two rooms.
  60. /// - When forced to backtrack, exits that "peek" into a room with its own unexplored
  61. /// option beyond it (an unused exit, or one leading somewhere this agent hasn't
  62. /// personally seen) are preferred over ones that only lead deeper into fully-picked-
  63. /// over territory - this steers the agent back out of a dead loop instead of just
  64. /// circling it.
  65. /// - Ties are broken toward whichever room this agent has personally visited the
  66. /// fewest times, so a small loop gets cycled through rather than bounced around.
  67. ///
  68. /// Also evaluates monster threats in adjacent rooms - if a room looks too dangerous,
  69. /// the agent will retreat (choose a different exit) if possible.
  70. /// </summary>
  71. private Exit ChooseExit(MazeAgent agent, Room room)
  72. {
  73. Exit reverseExit = FindReverseExit(agent, room);
  74. var explore = new List<Exit>();
  75. var follow = new List<Exit>();
  76. var backtrack = new List<Exit>();
  77. foreach (var exit in room.Exits)
  78. {
  79. if (exit == reverseExit) continue;
  80. if (exit.IsBlocked) continue; // Skip exits that are permanently unusable
  81. if (!exit.IsUsed)
  82. {
  83. explore.Add(exit);
  84. continue;
  85. }
  86. bool leadsToVisited = exit.ConnectedRoomId.HasValue && agent.HasVisited(exit.ConnectedRoomId.Value);
  87. (leadsToVisited ? backtrack : follow).Add(exit);
  88. }
  89. // Filter exits by threat level - remove dangerous ones if safer options exist
  90. explore = FilterByThreat(explore, agent);
  91. follow = FilterByThreat(follow, agent);
  92. backtrack = FilterByThreat(backtrack, agent);
  93. if (explore.Count > 0 && UnityEngine.Random.value < _exploreChance)
  94. return RandomPick(explore);
  95. if (follow.Count > 0)
  96. return LeastVisitedPick(agent, follow);
  97. if (explore.Count > 0)
  98. return RandomPick(explore);
  99. if (backtrack.Count > 0)
  100. return ChooseBacktrack(agent, backtrack);
  101. // Excluding the reverse door left nothing - it's genuinely the only way out
  102. // (e.g. a single-exit dead end), so take it (even if dangerous).
  103. return reverseExit;
  104. }
  105. /// <summary>The door in `room` that leads straight back to wherever the agent's last move came from, if any.</summary>
  106. private static Exit FindReverseExit(MazeAgent agent, Room room)
  107. {
  108. int? reverseExitId = agent.LastUsedExit?.ConnectedExitId;
  109. if (!reverseExitId.HasValue) return null;
  110. return room.Exits.Find(e => e.Id == reverseExitId.Value);
  111. }
  112. private Exit ChooseBacktrack(MazeAgent agent, List<Exit> backtrack)
  113. {
  114. var promising = backtrack.Where(e => LeadsTowardUnexploredOptions(agent, e)).ToList();
  115. return LeastVisitedPick(agent, promising.Count > 0 ? promising : backtrack);
  116. }
  117. /// <summary>
  118. /// One-step lookahead: does the room beyond `exit` itself have any door (other than
  119. /// the one leading straight back here) that's still unused or leads somewhere this
  120. /// agent hasn't personally been? If so, backtracking this way still has somewhere
  121. /// useful to go; if not, it just leads deeper into already-picked-over territory.
  122. /// </summary>
  123. private bool LeadsTowardUnexploredOptions(MazeAgent agent, Exit exit)
  124. {
  125. if (!exit.ConnectedRoomId.HasValue) return false;
  126. Room target = _generator.Graph.Get(exit.ConnectedRoomId.Value);
  127. if (target == null) return false;
  128. int reverseExitId = exit.ConnectedExitId ?? -1;
  129. foreach (var candidate in target.Exits)
  130. {
  131. if (candidate.Id == reverseExitId) continue;
  132. if (!candidate.IsUsed) return true;
  133. if (candidate.ConnectedRoomId.HasValue && !agent.HasVisited(candidate.ConnectedRoomId.Value)) return true;
  134. }
  135. return false;
  136. }
  137. private static Exit LeastVisitedPick(MazeAgent agent, List<Exit> exits)
  138. {
  139. int bestVisits = int.MaxValue;
  140. var bestExits = new List<Exit>();
  141. foreach (var exit in exits)
  142. {
  143. int visits = exit.ConnectedRoomId.HasValue ? agent.VisitCount(exit.ConnectedRoomId.Value) : 0;
  144. if (visits < bestVisits)
  145. {
  146. bestVisits = visits;
  147. bestExits.Clear();
  148. bestExits.Add(exit);
  149. }
  150. else if (visits == bestVisits)
  151. {
  152. bestExits.Add(exit);
  153. }
  154. }
  155. return RandomPick(bestExits);
  156. }
  157. private static Exit RandomPick(List<Exit> exits) => exits[UnityEngine.Random.Range(0, exits.Count)];
  158. /// <summary>
  159. /// Peek into a room (via an exit) to see if there are monsters and how many.
  160. /// Returns the perceived challenge rating, or 0 if no monsters/already explored.
  161. /// Agent sees incomplete info (60-100% of actual monsters, doesn't know about hidden ones).
  162. /// </summary>
  163. public float PeekThroughExit(Exit exit, MazeAgent agent)
  164. {
  165. if (!exit.ConnectedRoomId.HasValue)
  166. return 0; // Unresolved exit - can't peek yet
  167. Room nextRoom = _generator.Graph.Get(exit.ConnectedRoomId.Value);
  168. if (nextRoom == null || nextRoom.Encounter == null)
  169. return 0; // No encounter in this room
  170. return nextRoom.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet);
  171. }
  172. /// <summary>
  173. /// Decide if an exit is worth taking based on threat level inside.
  174. /// Returns true if the exit is relatively safe or if the agent has no choice,
  175. /// false if the agent decides to retreat from this exit.
  176. /// </summary>
  177. private bool ShouldTakeExit(Exit exit, MazeAgent agent)
  178. {
  179. if (!exit.ConnectedRoomId.HasValue)
  180. return true; // Unresolved exit - proceed cautiously
  181. Room nextRoom = _generator.Graph.Get(exit.ConnectedRoomId.Value);
  182. if (nextRoom == null || nextRoom.Encounter == null)
  183. return true; // No encounter, safe to enter
  184. // Peek and evaluate threat
  185. float perceivedCR = nextRoom.Encounter.GetPerceivedChallengeRating(agent.CharacterSheet);
  186. // If threat is below threshold, take it. If above, retreat with some randomness.
  187. if (perceivedCR <= _retreatThreshold)
  188. return true; // Manageable threat
  189. // Higher difficulty = more likely to retreat, but add randomness for personality
  190. float retreatChance = Mathf.Clamp01((perceivedCR - _retreatThreshold) / 2.0f);
  191. return UnityEngine.Random.value > retreatChance;
  192. }
  193. /// <summary>
  194. /// Filter exits: remove those the agent wants to retreat from due to high threat.
  195. /// This is called during exit selection so dangerous rooms are avoided when possible.
  196. /// </summary>
  197. private List<Exit> FilterByThreat(List<Exit> exits, MazeAgent agent)
  198. {
  199. var safeExits = new List<Exit>();
  200. foreach (var exit in exits)
  201. {
  202. if (ShouldTakeExit(exit, agent))
  203. safeExits.Add(exit);
  204. }
  205. // If all exits are dangerous, agent must take one anyway (don't get stuck)
  206. return safeExits.Count > 0 ? safeExits : exits;
  207. }
  208. }
  209. }