MazeSceneBuilder.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. using System.Collections;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using UnityEngine;
  5. namespace MazeGen
  6. {
  7. /// <summary>
  8. /// Scene entry point: drop this on one empty GameObject in the scene and press Play.
  9. /// Generates the configured start rooms, then spawns and drives a configurable number
  10. /// of autonomous agents that explore the maze on their own (see AutonomousAgentBrain).
  11. /// The level ends when enough agents reach an exit room or a time limit is hit.
  12. ///
  13. /// Also owns the observation HUD:
  14. /// - simulation controls (pause/speed - the sim runs on SimulationClock, not Time.timeScale)
  15. /// - the agent list (click to have the camera follow one)
  16. /// - "Agent thoughts and info" for the followed agent, fed by its AgentThoughtLog
  17. /// - live fights with their join-window countdown and watch/simulate controls
  18. /// - the global combat log
  19. /// The HUD hides itself entirely while a fight is being watched, since BattleArenaView
  20. /// draws its own.
  21. /// </summary>
  22. [RequireComponent(typeof(MazeGenerator))]
  23. [RequireComponent(typeof(MonsterSpawner))]
  24. [RequireComponent(typeof(CombatDirector))]
  25. [RequireComponent(typeof(BattleArenaView))]
  26. public class MazeSceneBuilder : MonoBehaviour
  27. {
  28. [Header("Agents")]
  29. [SerializeField, Min(1)] private int agentCount = 1;
  30. [Tooltip("Each agent's spawn is delayed by a random amount in this range (seconds), so they enter the maze staggered over time instead of all at once.")]
  31. [SerializeField] private Vector2 spawnStaggerRange = new Vector2(0f, 3f);
  32. [Tooltip("Each agent's FIRST decision is delayed by a random amount in this range (seconds); every decision after that uses the steady decisionInterval.")]
  33. [SerializeField] private Vector2 initialDecisionDelayRange = new Vector2(0.5f, 2f);
  34. [Tooltip("Steady-state time (seconds) between an agent's decisions once it gets going.")]
  35. [SerializeField] private float decisionInterval = 1.5f;
  36. [Tooltip("Chance an agent explores a brand-new exit even when a 'follow' (known but personally-unvisited) option is also available.")]
  37. [SerializeField, Range(0f, 1f)] private float exploreChance = 0.85f;
  38. [Tooltip("Perceived challenge rating an agent will take on alone. Above this it avoids the room, or tries to sneak past instead of fighting.")]
  39. [SerializeField] private float retreatThreshold = 1.5f;
  40. [Header("Win conditions (at least one must be > 0)")]
  41. [SerializeField] private int targetFinishedAgentCount = 1;
  42. [SerializeField] private float timeLimitSeconds = 0f;
  43. private class AgentEntry
  44. {
  45. public MazeAgentController Controller;
  46. public PathVisualizer Path;
  47. public Color Color;
  48. }
  49. private MazeGenerator _generator;
  50. private MonsterSpawner _monsterSpawner;
  51. private CombatDirector _director;
  52. private MazeCameraController _cameraController;
  53. private readonly Dictionary<int, ExitMarker> _exitMarkers = new Dictionary<int, ExitMarker>();
  54. private readonly List<AgentEntry> _agents = new List<AgentEntry>();
  55. private AgentEntry _followedAgent;
  56. private bool _levelEnded;
  57. private float _elapsedTime;
  58. // UI Panels
  59. private StatusPanelUI _statusPanel;
  60. private FightPanelUI _fightPanel;
  61. private AgentPanelUI _agentPanel;
  62. private InfoPanelUI _infoPanel;
  63. private void Awake()
  64. {
  65. // Static state, and the editor can skip domain reload between Play sessions - clear
  66. // it so a stale pause hold from the last run can't freeze this one on startup.
  67. SimulationClock.Reset();
  68. _generator = GetComponent<MazeGenerator>();
  69. _generator.RoomGenerated += OnRoomGenerated;
  70. _generator.ExitAdded += OnExitAdded;
  71. _monsterSpawner = GetComponent<MonsterSpawner>();
  72. // RequireComponent only auto-adds when the script is attached in the editor, so a
  73. // scene saved before these existed would come up without them - add them if missing
  74. // rather than null-referencing through the whole HUD.
  75. _director = GetComponent<CombatDirector>() ?? gameObject.AddComponent<CombatDirector>();
  76. _director.FightResolved += OnFightResolved;
  77. var cam = Camera.main;
  78. if (cam != null)
  79. {
  80. _cameraController = cam.GetComponent<MazeCameraController>();
  81. if (_cameraController == null)
  82. _cameraController = cam.gameObject.AddComponent<MazeCameraController>();
  83. }
  84. // Initialize UI panels
  85. // Note: pass empty bounds to StatusPanelUI; ComputeMazeBounds() is called in the zoom callback
  86. _statusPanel = new StatusPanelUI(targetFinishedAgentCount, timeLimitSeconds, _director, _cameraController, new Bounds(Vector3.zero, Vector3.one));
  87. _fightPanel = new FightPanelUI(_director, _cameraController);
  88. _agentPanel = new AgentPanelUI();
  89. _infoPanel = new InfoPanelUI(_director);
  90. if (targetFinishedAgentCount <= 0 && timeLimitSeconds <= 0f)
  91. {
  92. Debug.LogWarning("[MazeGen] No end condition configured (targetFinishedAgentCount and timeLimitSeconds are both 0) - defaulting targetFinishedAgentCount to agentCount so the level can still end.");
  93. targetFinishedAgentCount = agentCount;
  94. }
  95. }
  96. private void Start()
  97. {
  98. var settings = _generator.Settings;
  99. int startRoomCount = Mathf.Max(1, settings.startRoomCount);
  100. var startRooms = new List<Room>();
  101. for (int i = 0; i < startRoomCount; i++)
  102. {
  103. Vector2 pos = startRoomCount > 1 ? PickRandomStartPosition(settings, startRooms) : Vector2.zero;
  104. startRooms.Add(_generator.GenerateStartRoom(pos));
  105. }
  106. _cameraController?.FrameBounds(ComputeMazeBounds());
  107. // Assign each agent to a start room, then spawn per-room with independent stagger
  108. var assignments = AssignAgentsToStartRooms(agentCount, startRooms.Count);
  109. for (int r = 0; r < startRooms.Count; r++)
  110. StartCoroutine(SpawnAgentsForRoomRoutine(startRooms[r], assignments[r]));
  111. }
  112. /// <summary>
  113. /// Picks a uniformly random point within a disk of radius `startRoomSpread` (instead
  114. /// of evenly around its edge), retrying a few times to avoid landing on top of a
  115. /// start room already placed this call.
  116. /// </summary>
  117. private static Vector2 PickRandomStartPosition(MazeGenerationSettings settings, List<Room> placed)
  118. {
  119. float spread = Mathf.Max(settings.startRoomSpread, 0.01f);
  120. float minDistance = settings.startRoomRadius * 2f;
  121. for (int attempt = 0; attempt < 30; attempt++)
  122. {
  123. Vector2 candidate = Random.insideUnitCircle * spread;
  124. bool overlapsPlaced = false;
  125. foreach (var room in placed)
  126. {
  127. if (Vector2.Distance(candidate, room.Position) < minDistance)
  128. {
  129. overlapsPlaced = true;
  130. break;
  131. }
  132. }
  133. if (!overlapsPlaced) return candidate;
  134. }
  135. return Random.insideUnitCircle * spread; // give up avoiding overlap, use whatever we last rolled
  136. }
  137. /// <summary>
  138. /// Randomly assigns each agent index (0..agentCount-1) to one of the start rooms, decided
  139. /// upfront before any staggering/spawning begins, so the same statistical distribution as
  140. /// today's "pick a random room per spawn" applies, but grouped so each room's agents can be
  141. /// staggered independently and concurrently with other rooms.
  142. /// </summary>
  143. private static List<int>[] AssignAgentsToStartRooms(int agentCount, int roomCount)
  144. {
  145. var result = new List<int>[roomCount];
  146. for (int r = 0; r < roomCount; r++)
  147. result[r] = new List<int>();
  148. for (int i = 0; i < agentCount; i++)
  149. result[Random.Range(0, roomCount)].Add(i);
  150. return result;
  151. }
  152. /// <summary>
  153. /// One independent instance of this runs per start room, all started concurrently from
  154. /// Start(). Staggers only the agents assigned to THIS room by spawnStaggerRange, so agents
  155. /// in different start rooms can spawn at the same wall-clock time while agents sharing a
  156. /// room are still staggered relative to each other, same as before.
  157. /// </summary>
  158. private IEnumerator SpawnAgentsForRoomRoutine(Room startRoom, List<int> agentIndices)
  159. {
  160. foreach (int index in agentIndices)
  161. {
  162. float delay = Random.Range(spawnStaggerRange.x, spawnStaggerRange.y);
  163. if (delay > 0f) yield return new WaitForSeconds(delay);
  164. SpawnAgent(startRoom, index);
  165. }
  166. }
  167. private void SpawnAgent(Room startRoom, int index)
  168. {
  169. var agentGO = new GameObject($"Agent_{index}");
  170. agentGO.transform.SetParent(transform, false);
  171. var controller = agentGO.AddComponent<MazeAgentController>();
  172. controller.Initialize(_generator);
  173. // Golden-ratio hue spread gives visually distinct colors even for many agents.
  174. Color color = Color.HSVToRGB((index * 0.618034f) % 1f, 0.65f, 1f);
  175. controller.Spawn(startRoom, color);
  176. var pathGO = new GameObject("Path");
  177. pathGO.transform.SetParent(agentGO.transform, false);
  178. pathGO.AddComponent<LineRenderer>();
  179. var path = pathGO.AddComponent<PathVisualizer>();
  180. path.AppendPoint(startRoom.Position);
  181. pathGO.SetActive(false); // only the followed agent's trail is shown
  182. var brain = agentGO.AddComponent<AutonomousAgentBrain>();
  183. float initialDelay = Random.Range(initialDecisionDelayRange.x, initialDecisionDelayRange.y);
  184. brain.Initialize(controller, _generator, _director, initialDelay, decisionInterval, exploreChance, retreatThreshold, () => _levelEnded);
  185. // The director needs the roster to know who else is standing in a room when a fight
  186. // breaks out there, and which color to draw each agent with in the arena.
  187. _director.RegisterAgent(controller, color);
  188. controller.Agent.RoomEntered += room => path.AppendPoint(room.Position);
  189. controller.Agent.RoomEntered += _ => RefreshMarkerVisuals();
  190. controller.Agent.RoomEntered += _ => CheckFinishCondition();
  191. controller.Agent.ReachedExitRoom += room => Debug.Log($"[MazeGen] {controller.Agent.DisplayName} reached the exit room (id {room.Id})!");
  192. _agents.Add(new AgentEntry { Controller = controller, Path = path, Color = color });
  193. }
  194. private void OnRoomGenerated(Room room)
  195. {
  196. var roomGO = new GameObject($"Room_{room.Id}_{room.Kind}");
  197. roomGO.transform.SetParent(transform, false);
  198. roomGO.AddComponent<RoomView>().Initialize(room);
  199. foreach (var exit in room.Exits)
  200. SpawnExitMarker(room, exit);
  201. }
  202. private void OnExitAdded(Room room, Exit exit)
  203. {
  204. SpawnExitMarker(room, exit);
  205. RefreshMarkerVisuals();
  206. }
  207. private void SpawnExitMarker(Room room, Exit exit)
  208. {
  209. var markerGO = new GameObject($"Exit_{exit.Id}");
  210. markerGO.transform.SetParent(transform, false);
  211. var marker = markerGO.AddComponent<ExitMarker>();
  212. marker.Initialize(room, exit);
  213. _exitMarkers[exit.Id] = marker;
  214. }
  215. private void RefreshMarkerVisuals()
  216. {
  217. Room currentRoom = _followedAgent?.Controller.Agent?.CurrentRoom;
  218. MazeAgent agent = _followedAgent?.Controller.Agent;
  219. foreach (var marker in _exitMarkers.Values)
  220. marker.UpdateVisual(currentRoom, agent);
  221. }
  222. /// <summary>A fight is over: drop the views of anything that died in it and recheck the level.</summary>
  223. private void OnFightResolved(PendingFight fight)
  224. {
  225. _monsterSpawner?.RefreshRoom(fight.Room);
  226. RefreshMarkerVisuals();
  227. CheckFinishCondition();
  228. }
  229. private void Update()
  230. {
  231. if (MazeInput.WasPauseTogglePressed())
  232. SimulationClock.UserPaused = !SimulationClock.UserPaused;
  233. if (_levelEnded) return;
  234. // The level clock is simulation time, so pausing to watch a fight doesn't burn it.
  235. _elapsedTime += SimulationClock.DeltaTime;
  236. if (timeLimitSeconds > 0f && _elapsedTime >= timeLimitSeconds)
  237. EndLevel($"Time limit of {timeLimitSeconds:0.0}s reached.");
  238. }
  239. private void CheckFinishCondition()
  240. {
  241. if (_levelEnded) return;
  242. int finished = _agents.Count(a => a.Controller.Agent.IsFinished);
  243. if (targetFinishedAgentCount > 0 && finished >= targetFinishedAgentCount)
  244. {
  245. EndLevel($"{finished} agent(s) reached the exit.");
  246. return;
  247. }
  248. if (_agents.Count > 0 && _agents.Count >= agentCount && _agents.All(a => a.Controller.Agent.IsDead || a.Controller.Agent.IsFinished))
  249. EndLevel($"Every agent is dead or finished ({finished} reached the exit).");
  250. }
  251. private void EndLevel(string reason)
  252. {
  253. _levelEnded = true;
  254. Debug.Log($"[MazeGen] LEVEL COMPLETE - {reason}");
  255. }
  256. private void FollowAgent(AgentEntry entry)
  257. {
  258. if (_followedAgent == entry) return;
  259. StopFollowing();
  260. _followedAgent = entry;
  261. entry.Controller.SetFollowed(true);
  262. entry.Path.gameObject.SetActive(true);
  263. entry.Controller.Agent.RoomEntered += OnFollowedAgentMoved;
  264. _cameraController?.FocusOn(entry.Controller.Agent.CurrentRoom.Position);
  265. RefreshMarkerVisuals();
  266. }
  267. private void StopFollowing()
  268. {
  269. if (_followedAgent == null) return;
  270. _followedAgent.Controller.SetFollowed(false);
  271. _followedAgent.Path.gameObject.SetActive(false);
  272. _followedAgent.Controller.Agent.RoomEntered -= OnFollowedAgentMoved;
  273. _followedAgent = null;
  274. RefreshMarkerVisuals();
  275. }
  276. private void OnFollowedAgentMoved(Room room) => _cameraController?.FocusOn(room.Position);
  277. private Bounds ComputeMazeBounds()
  278. {
  279. var rooms = _generator.Graph.Rooms.Values;
  280. if (rooms.Count == 0) return new Bounds(Vector3.zero, Vector3.one * 8f);
  281. var bounds = new Bounds(rooms.First().Position, Vector3.zero);
  282. foreach (var room in rooms)
  283. bounds.Encapsulate(new Bounds(room.Position, Vector3.one * (room.Radius * 2f)));
  284. return bounds;
  285. }
  286. // ---------------------------------------------------------------- HUD
  287. private void OnGUI()
  288. {
  289. // The arena draws its own HUD and owns the screen while a fight is being watched.
  290. if (_director != null && _director.IsWatching) return;
  291. // Draw status panel (top-left)
  292. _statusPanel?.Draw(
  293. _elapsedTime,
  294. _agents.Count,
  295. _agents.Count(a => a.Controller.Agent.IsFinished),
  296. _agents.Count(a => a.Controller.Agent.IsDead),
  297. _levelEnded,
  298. () => _cameraController?.FrameBounds(ComputeMazeBounds())
  299. );
  300. // Draw fight panel (center-top)
  301. _fightPanel?.Draw();
  302. // Draw agent list (left, below status)
  303. var clickedAgent = _agentPanel?.Draw(
  304. _agents.Select(a => a.Controller.Agent).ToList(),
  305. _followedAgent?.Controller.Agent,
  306. _followedAgent != null,
  307. StopFollowing
  308. );
  309. if (clickedAgent != null)
  310. {
  311. var entry = _agents.FirstOrDefault(a => a.Controller.Agent == clickedAgent);
  312. if (entry != null)
  313. FollowAgent(entry);
  314. }
  315. // Draw info panel (right side)
  316. _infoPanel?.Draw(_followedAgent?.Controller.Agent);
  317. }
  318. }
  319. }