MazeGenerator.cs 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. using System;
  2. using System.Collections.Generic;
  3. using UnityEngine;
  4. namespace MazeGen
  5. {
  6. /// <summary>
  7. /// Owns the MazeGraph and knows how to grow it one room at a time, on demand
  8. /// (i.e. lazily - a room beyond an exit only exists once something actually
  9. /// walks through that exit). This is what makes "huge quantity of rooms" cheap:
  10. /// nothing is generated until it's needed.
  11. /// </summary>
  12. public class MazeGenerator : MonoBehaviour
  13. {
  14. private static readonly float[] CardinalAngles = { 0f, 90f, 180f, 270f };
  15. [SerializeField] private MazeGenerationSettings settings;
  16. private MazeGraph _graph;
  17. public MazeGraph Graph => _graph;
  18. public MazeGenerationSettings Settings => settings;
  19. /// <summary>Fired whenever a new room (including start rooms) is created.</summary>
  20. public event Action<Room> RoomGenerated;
  21. /// <summary>Fired whenever an exit is resolved to a connected room (new room, or a connection into an existing one).</summary>
  22. public event Action<Room, Exit, Room> RoomsConnected;
  23. /// <summary>Fired when a door is added to an already-registered room after the fact (see TryConnectViaExistingDoor), so its marker can be spawned.</summary>
  24. public event Action<Room, Exit> ExitAdded;
  25. private void Awake()
  26. {
  27. if (settings == null)
  28. settings = ScriptableObject.CreateInstance<MazeGenerationSettings>();
  29. _graph = new MazeGraph(settings.roomSpacing);
  30. }
  31. public Room GenerateStartRoom(Vector2 position)
  32. {
  33. int exitCount = UnityEngine.Random.Range(settings.minExitsPerRoom, settings.maxExitsPerRoom + 1);
  34. var angles = PickCardinalAngles(exitCount, null);
  35. var room = new Room(_graph.AllocateRoomId(), RoomKind.Start, position, settings.startRoomRadius, 0);
  36. foreach (var angle in angles)
  37. _graph.AddExit(room, new Exit { Id = _graph.AllocateExitId(), OwnerRoomId = room.Id, DirectionDegrees = angle });
  38. _graph.Register(room);
  39. RoomGenerated?.Invoke(room);
  40. return room;
  41. }
  42. /// <summary>
  43. /// Resolves an exit: if it already leads somewhere, returns that room. Otherwise first
  44. /// checks whether the spawn spot would overlap an existing room that has an unused door
  45. /// facing back toward fromRoom - if so, connects into that door (straight, or via a
  46. /// single 90-degree elbow hallway) instead of creating a new, overlapping room. Failing
  47. /// that, if the spawn spot overlaps anything, tries routing around it via cardinal
  48. /// directions. If all cardinal routing fails, retries TryConnectViaExistingDoor with a
  49. /// much wider search radius. If that also fails, marks the exit as blocked and returns
  50. /// null (agent will pick a different exit next decision tick).
  51. /// </summary>
  52. public Room GenerateConnectedRoom(Room fromRoom, Exit fromExit, MazeAgent agent)
  53. {
  54. if (fromExit.ConnectedRoomId.HasValue)
  55. return _graph.Get(fromExit.ConnectedRoomId.Value);
  56. RoomKind kind = DecideNextRoomKind(agent, fromRoom);
  57. float entryAngle = NormalizeAngle(fromExit.DirectionDegrees + 180f);
  58. Vector2 position = fromRoom.Position + fromExit.Direction * settings.roomSpacing;
  59. float radius = RadiusFor(kind);
  60. Room connectedThroughDoor = TryConnectViaExistingDoor(fromRoom, fromExit, position, radius);
  61. if (connectedThroughDoor != null)
  62. return connectedThroughDoor;
  63. if (HasOverlap(position, radius, fromRoom.Id))
  64. {
  65. if (TryRouteAroundObstruction(fromRoom, fromExit, radius, out Vector2 routedPosition, out Vector2? bendCorner, out float routedEntryAngle))
  66. {
  67. if (bendCorner.HasValue)
  68. return CreateRoomViaBend(fromRoom, fromExit, kind, bendCorner.Value, routedPosition, radius, routedEntryAngle);
  69. position = routedPosition;
  70. entryAngle = routedEntryAngle;
  71. }
  72. else
  73. {
  74. // Routing failed - try one more time with a much wider search in TryConnectViaExistingDoor
  75. float extraSearchRadius = settings.maxRouteSearchSteps * settings.repositionRingStep;
  76. Room wideConnected = TryConnectViaExistingDoorWidened(fromRoom, fromExit, position, radius, extraSearchRadius);
  77. if (wideConnected != null)
  78. return wideConnected;
  79. // All options exhausted - mark the exit as blocked and refuse to generate
  80. Debug.LogError($"[MazeGen] Could not find any collision-free, path-clear spot for a room beyond room {fromRoom.Id}'s exit {fromExit.Id} even after widening the search. Blocking this exit permanently.");
  81. fromExit.IsBlocked = true;
  82. return null;
  83. }
  84. }
  85. return BuildRoom(kind, position, radius, fromRoom.DepthFromStart + 1, fromRoom, fromExit, entryAngle);
  86. }
  87. /// <summary>
  88. /// Creates a room of the given kind at `position`, wires its entry door to
  89. /// `connectingExit` (owned by `connectingRoom`), rolls its extra exits, registers it
  90. /// and fires the generation/connection events. Shared by the plain straight-line
  91. /// spawn path and the bend-routing path (where the "connecting" side is a freshly
  92. /// built elbow hallway rather than fromRoom itself).
  93. /// </summary>
  94. private Room BuildRoom(RoomKind kind, Vector2 position, float radius, int depthFromStart, Room connectingRoom, Exit connectingExit, float entryAngle)
  95. {
  96. var room = new Room(_graph.AllocateRoomId(), kind, position, radius, depthFromStart);
  97. var entryExit = new Exit
  98. {
  99. Id = _graph.AllocateExitId(),
  100. OwnerRoomId = room.Id,
  101. DirectionDegrees = entryAngle
  102. };
  103. _graph.AddExit(room, entryExit);
  104. if (kind != RoomKind.Exit)
  105. {
  106. int extraExits = kind == RoomKind.Hallway
  107. ? 1
  108. : Mathf.Max(0, UnityEngine.Random.Range(settings.minExitsPerRoom, settings.maxExitsPerRoom + 1) - 1);
  109. // Safety net: never let this generation be the one that drains this branch's
  110. // last unused exit before an Exit room exists anywhere in it. Scoped to
  111. // connectingRoom's own connected component - a different, disconnected branch
  112. // already having an exit doesn't help this one.
  113. int projectedPool = _graph.ComponentUnusedExitCount(connectingRoom.Id) - 1 + extraExits;
  114. if (projectedPool <= 0 && !_graph.ComponentHasExit(connectingRoom.Id))
  115. extraExits = Mathf.Max(extraExits, 1);
  116. var extraAngles = PickCardinalAngles(extraExits, entryAngle);
  117. foreach (var angle in extraAngles)
  118. _graph.AddExit(room, new Exit { Id = _graph.AllocateExitId(), OwnerRoomId = room.Id, DirectionDegrees = angle });
  119. }
  120. ConnectExits(connectingExit, entryExit);
  121. _graph.Register(room);
  122. RoomGenerated?.Invoke(room);
  123. RoomsConnected?.Invoke(connectingRoom, connectingExit, room);
  124. return room;
  125. }
  126. /// <summary>
  127. /// Called only once the straight-line spawn spot is known to overlap something and no
  128. /// existing door offered a connection. First tries extending further out along the
  129. /// exit's own direction; if that never clears, tries a single 90-degree bend (to
  130. /// either side) at increasing distances. Each candidate is checked both for a clear
  131. /// footprint and a clear path leading to it. Returns false if nothing within the
  132. /// search range works, in which case the caller falls back to a plain nudge.
  133. /// </summary>
  134. private bool TryRouteAroundObstruction(Room fromRoom, Exit fromExit, float radius,
  135. out Vector2 finalPosition, out Vector2? bendCorner, out float finalEntryAngle)
  136. {
  137. Vector2 dir = fromExit.Direction;
  138. Vector2 edgeFrom = fromRoom.Position + dir * fromRoom.Radius;
  139. float straightEntryAngle = NormalizeAngle(fromExit.DirectionDegrees + 180f);
  140. for (int i = 0; i < settings.maxRouteSearchSteps; i++)
  141. {
  142. float distance = settings.roomSpacing + i * settings.repositionRingStep;
  143. Vector2 candidate = fromRoom.Position + dir * distance;
  144. if (!HasOverlap(candidate, radius, fromRoom.Id) && IsPathClear(edgeFrom, candidate, settings.hallwayRadius, fromRoom.Id, -1))
  145. {
  146. finalPosition = candidate;
  147. bendCorner = null;
  148. finalEntryAngle = straightEntryAngle;
  149. return true;
  150. }
  151. }
  152. Vector2[] perpendiculars = { new Vector2(-dir.y, dir.x), new Vector2(dir.y, -dir.x) };
  153. for (int i = 0; i < settings.maxRouteSearchSteps; i++)
  154. {
  155. float cornerDistance = settings.roomSpacing + i * settings.repositionRingStep;
  156. Vector2 corner = fromRoom.Position + dir * cornerDistance;
  157. if (!IsPathClear(edgeFrom, corner, settings.hallwayRadius, fromRoom.Id, -1)) continue;
  158. if (HasOverlap(corner, settings.hallwayRadius, fromRoom.Id)) continue;
  159. foreach (var perp in perpendiculars)
  160. {
  161. for (int j = 0; j < settings.maxRouteSearchSteps; j++)
  162. {
  163. float sideDistance = settings.roomSpacing + j * settings.repositionRingStep;
  164. Vector2 candidate = corner + perp * sideDistance;
  165. if (HasOverlap(candidate, radius, fromRoom.Id)) continue;
  166. if (!IsPathClear(corner, candidate, settings.hallwayRadius, fromRoom.Id, -1)) continue;
  167. finalPosition = candidate;
  168. bendCorner = corner;
  169. finalEntryAngle = NormalizeAngle(AngleOf(perp) + 180f);
  170. return true;
  171. }
  172. }
  173. }
  174. finalPosition = fromRoom.Position + dir * settings.roomSpacing;
  175. bendCorner = null;
  176. finalEntryAngle = straightEntryAngle;
  177. return false;
  178. }
  179. /// <summary>
  180. /// Builds the elbow hallway room at `corner` connecting fromExit to it, then builds
  181. /// the brand-new destination room beyond the bend and wires the hallway's forward
  182. /// door into it. Returns the hallway - the room fromExit now immediately leads to.
  183. /// </summary>
  184. private Room CreateRoomViaBend(Room fromRoom, Exit fromExit, RoomKind kind, Vector2 corner, Vector2 finalPosition, float radius, float entryAngle)
  185. {
  186. var hallway = new Room(_graph.AllocateRoomId(), RoomKind.Hallway, corner, settings.hallwayRadius, fromRoom.DepthFromStart + 1);
  187. var backExit = new Exit
  188. {
  189. Id = _graph.AllocateExitId(),
  190. OwnerRoomId = hallway.Id,
  191. DirectionDegrees = NormalizeAngle(fromExit.DirectionDegrees + 180f)
  192. };
  193. var forwardExit = new Exit
  194. {
  195. Id = _graph.AllocateExitId(),
  196. OwnerRoomId = hallway.Id,
  197. DirectionDegrees = NormalizeAngle(entryAngle + 180f)
  198. };
  199. _graph.AddExit(hallway, backExit);
  200. _graph.AddExit(hallway, forwardExit);
  201. Vector2 pFrom = fromRoom.Position + fromExit.Direction * fromRoom.Radius;
  202. Vector2 pInto = finalPosition + DirectionFromAngle(entryAngle) * radius;
  203. hallway.CorridorWaypoints = new[] { pFrom, corner, pInto };
  204. ConnectExits(fromExit, backExit);
  205. _graph.Register(hallway);
  206. RoomGenerated?.Invoke(hallway);
  207. RoomsConnected?.Invoke(fromRoom, fromExit, hallway);
  208. BuildRoom(kind, finalPosition, radius, hallway.DepthFromStart + 1, hallway, forwardExit, entryAngle);
  209. return hallway;
  210. }
  211. /// <summary>
  212. /// Looks for an already-placed room whose footprint overlaps the candidate spawn
  213. /// position, on the side that faces fromRoom. Prefers an existing unused exit there;
  214. /// if that side simply has no door on it yet, adds one on the fly instead (not the
  215. /// most "logical" world - the room retroactively grows a doorway - but it closes far
  216. /// more loops than only ever matching pre-existing doors, and keeps rooms from just
  217. /// sitting on top of each other). If one is found (and the resulting path can be
  218. /// routed without crossing anything else), fromExit is wired into that door instead of
  219. /// a brand new room being created. Returns the room fromExit now leads to, or null if
  220. /// no overlapping room offers a usable, reachable door.
  221. /// </summary>
  222. /// <summary>
  223. /// Last-resort version of TryConnectViaExistingDoor: searches with a much larger radius
  224. /// to find *any* room that can be reached and merged, not just ones overlapping the
  225. /// immediate candidate spot.
  226. /// </summary>
  227. private Room TryConnectViaExistingDoorWidened(Room fromRoom, Exit fromExit, Vector2 candidatePosition, float candidateRadius, float extraSearchRadius)
  228. {
  229. float searchRadius = candidateRadius + MaxRoomRadius() + extraSearchRadius;
  230. var candidates = new List<Room>();
  231. foreach (var other in _graph.QueryRoomsNear(fromRoom.Position, searchRadius))
  232. {
  233. if (other.Id == fromRoom.Id) continue;
  234. candidates.Add(other);
  235. }
  236. // Sort by distance to fromRoom position (try nearest first)
  237. candidates.Sort((a, b) => Vector2.Distance(fromRoom.Position, a.Position).CompareTo(Vector2.Distance(fromRoom.Position, b.Position)));
  238. foreach (var other in candidates)
  239. {
  240. Vector2 towardFromRoom = fromRoom.Position - other.Position;
  241. if (towardFromRoom.sqrMagnitude < 0.0001f) continue;
  242. float desiredAngle = NearestCardinal(Mathf.Atan2(towardFromRoom.y, towardFromRoom.x) * Mathf.Rad2Deg);
  243. Exit targetExit = null;
  244. foreach (var exit in other.UnusedExits)
  245. {
  246. if (Mathf.Approximately(NormalizeAngle(exit.DirectionDegrees), desiredAngle))
  247. {
  248. targetExit = exit;
  249. break;
  250. }
  251. }
  252. bool needsNewExit = targetExit == null;
  253. if (needsNewExit)
  254. {
  255. bool sideOccupied = other.Exits.Exists(e => Mathf.Approximately(NormalizeAngle(e.DirectionDegrees), desiredAngle));
  256. if (sideOccupied) continue;
  257. if (other.Exits.Count >= settings.maxExitsPerRoom) continue;
  258. }
  259. int exitsConsumed = needsNewExit ? 1 : 2;
  260. if (WouldStarveComponent(fromRoom, other, exitsConsumed)) continue;
  261. if (needsNewExit)
  262. {
  263. targetExit = new Exit { Id = _graph.AllocateExitId(), OwnerRoomId = other.Id, DirectionDegrees = desiredAngle };
  264. _graph.AddExit(other, targetExit);
  265. }
  266. Room connected = TryBuildDoorConnection(fromRoom, fromExit, other, targetExit);
  267. if (connected != null)
  268. {
  269. if (needsNewExit) ExitAdded?.Invoke(other, targetExit);
  270. return connected;
  271. }
  272. if (needsNewExit)
  273. _graph.RemoveUnusedExit(other, targetExit);
  274. }
  275. return null;
  276. }
  277. private Room TryConnectViaExistingDoor(Room fromRoom, Exit fromExit, Vector2 candidatePosition, float candidateRadius)
  278. {
  279. float searchRadius = candidateRadius + MaxRoomRadius();
  280. var overlapping = new List<Room>();
  281. foreach (var other in _graph.QueryRoomsNear(candidatePosition, searchRadius))
  282. {
  283. if (other.Id == fromRoom.Id) continue;
  284. if (!SquaresOverlap(candidatePosition, candidateRadius, other.Position, other.Radius)) continue;
  285. overlapping.Add(other);
  286. }
  287. overlapping.Sort((a, b) =>
  288. Vector2.Distance(candidatePosition, a.Position).CompareTo(Vector2.Distance(candidatePosition, b.Position)));
  289. foreach (var other in overlapping)
  290. {
  291. Vector2 towardFromRoom = fromRoom.Position - other.Position;
  292. if (towardFromRoom.sqrMagnitude < 0.0001f) continue;
  293. float desiredAngle = NearestCardinal(Mathf.Atan2(towardFromRoom.y, towardFromRoom.x) * Mathf.Rad2Deg);
  294. Exit targetExit = null;
  295. foreach (var exit in other.UnusedExits)
  296. {
  297. if (Mathf.Approximately(NormalizeAngle(exit.DirectionDegrees), desiredAngle))
  298. {
  299. targetExit = exit;
  300. break;
  301. }
  302. }
  303. bool needsNewExit = targetExit == null;
  304. if (needsNewExit)
  305. {
  306. // That side's already spoken for by a different (used) door - can't add
  307. // one here, this room can't be the merge target.
  308. bool sideOccupied = other.Exits.Exists(e => Mathf.Approximately(NormalizeAngle(e.DirectionDegrees), desiredAngle));
  309. if (sideOccupied) continue;
  310. // Respect the configured per-room exit cap even for doors added on the fly.
  311. if (other.Exits.Count >= settings.maxExitsPerRoom) continue;
  312. }
  313. // A connection consumes fromExit (+ targetExit, unless it's a freshly added
  314. // door, which nets to zero). Refuse it if that would leave the merged
  315. // component (fromRoom's + other's, which this connection is about to join)
  316. // with zero unused exits and no Exit room in it - fall back to normal
  317. // generation instead, which always can add fresh exits.
  318. int exitsConsumed = needsNewExit ? 1 : 2;
  319. if (WouldStarveComponent(fromRoom, other, exitsConsumed)) continue;
  320. if (needsNewExit)
  321. {
  322. targetExit = new Exit { Id = _graph.AllocateExitId(), OwnerRoomId = other.Id, DirectionDegrees = desiredAngle };
  323. _graph.AddExit(other, targetExit);
  324. }
  325. Room connected = TryBuildDoorConnection(fromRoom, fromExit, other, targetExit);
  326. if (connected != null)
  327. {
  328. if (needsNewExit) ExitAdded?.Invoke(other, targetExit);
  329. return connected;
  330. }
  331. // Didn't pan out (blocked path) - don't leave a dangling unusable door behind.
  332. if (needsNewExit)
  333. _graph.RemoveUnusedExit(other, targetExit);
  334. }
  335. return null;
  336. }
  337. /// <summary>
  338. /// Builds the actual path between two facing doors: a direct link if they already
  339. /// face each other head-on, or a single 90-degree elbow hallway room if they're on
  340. /// perpendicular sides. Returns null (without mutating anything) if the geometry
  341. /// isn't supported (doors facing the same way) or the path would cross another
  342. /// room/hallway.
  343. /// </summary>
  344. private Room TryBuildDoorConnection(Room fromRoom, Exit fromExit, Room other, Exit targetExit)
  345. {
  346. float d1 = NormalizeAngle(fromExit.DirectionDegrees);
  347. float d2 = NormalizeAngle(targetExit.DirectionDegrees);
  348. float absDiff = Mathf.Abs(Mathf.DeltaAngle(d1, d2));
  349. if (Mathf.Approximately(absDiff, 180f))
  350. return ConnectStraight(fromRoom, fromExit, other, targetExit);
  351. if (Mathf.Approximately(absDiff, 90f))
  352. return ConnectWithElbow(fromRoom, fromExit, other, targetExit);
  353. // Doors facing the same direction would require a U-turn (two bends) - not
  354. // supported by the single-elbow hallway model.
  355. return null;
  356. }
  357. private Room ConnectStraight(Room fromRoom, Exit fromExit, Room other, Exit targetExit)
  358. {
  359. // Defensive check: the two rooms shouldn't already overlap
  360. if (SquaresOverlap(fromRoom.Position, fromRoom.Radius, other.Position, other.Radius))
  361. {
  362. Debug.LogWarning($"[MazeGen] ConnectStraight refused: room {fromRoom.Id} and room {other.Id} already overlap.");
  363. return null;
  364. }
  365. Vector2 pFrom = fromRoom.Position + fromExit.Direction * fromRoom.Radius;
  366. Vector2 pTo = other.Position + targetExit.Direction * other.Radius;
  367. if (!IsPathClear(pFrom, pTo, settings.hallwayRadius, fromRoom.Id, other.Id)) return null;
  368. ConnectExits(fromExit, targetExit);
  369. RoomsConnected?.Invoke(fromRoom, fromExit, other);
  370. return other;
  371. }
  372. private Room ConnectWithElbow(Room fromRoom, Exit fromExit, Room other, Exit targetExit)
  373. {
  374. Vector2 pFrom = fromRoom.Position + fromExit.Direction * (fromRoom.Radius + settings.hallwayRadius);
  375. Vector2 pTo = other.Position + targetExit.Direction * (other.Radius + settings.hallwayRadius);
  376. bool fromIsHorizontal = Mathf.Abs(fromExit.Direction.x) > 0.5f;
  377. Vector2 corner = fromIsHorizontal ? new Vector2(pTo.x, pFrom.y) : new Vector2(pFrom.x, pTo.y);
  378. // The elbow must travel outward from each door, never doubling back through the
  379. // room it just left or approaching the target door from the wrong side.
  380. if (Vector2.Dot(corner - pFrom, fromExit.Direction) <= 0f) return null;
  381. if (Vector2.Dot(pTo - corner, -targetExit.Direction) <= 0f) return null;
  382. if (!IsPathClear(pFrom, corner, settings.hallwayRadius, fromRoom.Id, other.Id)) return null;
  383. if (!IsPathClear(corner, pTo, settings.hallwayRadius, fromRoom.Id, other.Id)) return null;
  384. if (HasOverlap(corner, settings.hallwayRadius, fromRoom.Id, other.Id)) return null;
  385. var hallway = new Room(_graph.AllocateRoomId(), RoomKind.Hallway, corner, settings.hallwayRadius, fromRoom.DepthFromStart + 1);
  386. var backExit = new Exit
  387. {
  388. Id = _graph.AllocateExitId(),
  389. OwnerRoomId = hallway.Id,
  390. DirectionDegrees = NormalizeAngle(fromExit.DirectionDegrees + 180f)
  391. };
  392. var forwardExit = new Exit
  393. {
  394. Id = _graph.AllocateExitId(),
  395. OwnerRoomId = hallway.Id,
  396. DirectionDegrees = NormalizeAngle(targetExit.DirectionDegrees + 180f)
  397. };
  398. _graph.AddExit(hallway, backExit);
  399. _graph.AddExit(hallway, forwardExit);
  400. hallway.CorridorWaypoints = new[] { pFrom, corner, pTo };
  401. ConnectExits(fromExit, backExit);
  402. ConnectExits(forwardExit, targetExit);
  403. _graph.Register(hallway);
  404. RoomGenerated?.Invoke(hallway);
  405. RoomsConnected?.Invoke(fromRoom, fromExit, hallway);
  406. return hallway;
  407. }
  408. /// <summary>True if any registered room or corridor segment (other than those excluded) overlaps a square at position/radius.</summary>
  409. private bool HasOverlap(Vector2 position, float radius, params int[] excludeRoomIds)
  410. {
  411. float searchRadius = radius + MaxRoomRadius();
  412. foreach (var room in _graph.QueryRoomsNear(position, searchRadius))
  413. {
  414. if (Array.IndexOf(excludeRoomIds, room.Id) >= 0) continue;
  415. if (SquaresOverlap(position, radius, room.Position, room.Radius)) return true;
  416. }
  417. foreach (var seg in _graph.QueryCorridorSegmentsNear(position, radius))
  418. {
  419. if (Array.IndexOf(excludeRoomIds, seg.OwnerRoomId) >= 0) continue;
  420. if (SquareOverlapsRect(position, radius, seg.Min, seg.Max)) return true;
  421. }
  422. return false;
  423. }
  424. /// <summary>True if the axis-aligned corridor rectangle from a to b (thickness 2*halfWidth) stays clear of every room's square footprint and every other registered corridor segment (other than those excluded).</summary>
  425. private bool IsPathClear(Vector2 a, Vector2 b, float halfWidth, int excludeRoomId1, int excludeRoomId2)
  426. {
  427. Vector2 min = new Vector2(Mathf.Min(a.x, b.x), Mathf.Min(a.y, b.y));
  428. Vector2 max = new Vector2(Mathf.Max(a.x, b.x), Mathf.Max(a.y, b.y));
  429. // Pad only in the thin (perpendicular) axis
  430. if (Mathf.Approximately(min.y, max.y))
  431. {
  432. min.y -= halfWidth;
  433. max.y += halfWidth;
  434. }
  435. else
  436. {
  437. min.x -= halfWidth;
  438. max.x += halfWidth;
  439. }
  440. // Add clearance buffer
  441. min -= Vector2.one * settings.hallwayClearance;
  442. max += Vector2.one * settings.hallwayClearance;
  443. Vector2 mid = (a + b) * 0.5f;
  444. float searchRadius = Vector2.Distance(a, b) * 0.5f + MaxRoomRadius() + halfWidth + settings.hallwayClearance;
  445. foreach (var room in _graph.QueryRoomsNear(mid, searchRadius))
  446. {
  447. if (room.Id == excludeRoomId1 || room.Id == excludeRoomId2) continue;
  448. if (SquareOverlapsRect(room.Position, room.Radius, min, max)) return false;
  449. }
  450. foreach (var seg in _graph.QueryCorridorSegmentsNear(mid, searchRadius))
  451. {
  452. if (seg.OwnerRoomId == excludeRoomId1 || seg.OwnerRoomId == excludeRoomId2) continue;
  453. if (RectsOverlap(min, max, seg.Min, seg.Max)) return false;
  454. }
  455. return true;
  456. }
  457. // AABB overlap primitives (axis-aligned, since all directions are cardinal)
  458. /// <summary>True if two axis-aligned squares (centers posA/posB, half-sides rA/rB) overlap.</summary>
  459. private static bool SquaresOverlap(Vector2 posA, float rA, Vector2 posB, float rB) =>
  460. Mathf.Abs(posA.x - posB.x) < rA + rB && Mathf.Abs(posA.y - posB.y) < rA + rB;
  461. /// <summary>True if an axis-aligned square (pos/radius) overlaps an axis-aligned rect [min,max].</summary>
  462. private static bool SquareOverlapsRect(Vector2 pos, float radius, Vector2 rectMin, Vector2 rectMax) =>
  463. pos.x + radius > rectMin.x && pos.x - radius < rectMax.x &&
  464. pos.y + radius > rectMin.y && pos.y - radius < rectMax.y;
  465. /// <summary>True if two axis-aligned rects overlap.</summary>
  466. private static bool RectsOverlap(Vector2 aMin, Vector2 aMax, Vector2 bMin, Vector2 bMax) =>
  467. aMin.x < bMax.x && aMax.x > bMin.x && aMin.y < bMax.y && aMax.y > bMin.y;
  468. /// <summary>
  469. /// True if connecting fromRoom and other (consuming `exitsConsumed` unused exits in
  470. /// the process) would leave their merged connected component with no unused exits and
  471. /// no Exit room - i.e. a dead branch that could never produce a reachable exit.
  472. /// </summary>
  473. private bool WouldStarveComponent(Room fromRoom, Room other, int exitsConsumed)
  474. {
  475. bool sameComponent = _graph.AreSameComponent(fromRoom.Id, other.Id);
  476. bool mergedHasExit = _graph.ComponentHasExit(fromRoom.Id)
  477. || (!sameComponent && _graph.ComponentHasExit(other.Id));
  478. if (mergedHasExit) return false;
  479. int mergedUnused = _graph.ComponentUnusedExitCount(fromRoom.Id);
  480. if (!sameComponent) mergedUnused += _graph.ComponentUnusedExitCount(other.Id);
  481. return mergedUnused - exitsConsumed < 1;
  482. }
  483. private void ConnectExits(Exit a, Exit b)
  484. {
  485. _graph.MarkExitUsed(a);
  486. _graph.MarkExitUsed(b);
  487. a.ConnectedRoomId = b.OwnerRoomId;
  488. a.ConnectedExitId = b.Id;
  489. b.ConnectedRoomId = a.OwnerRoomId;
  490. b.ConnectedExitId = a.Id;
  491. _graph.Union(a.OwnerRoomId, b.OwnerRoomId);
  492. }
  493. private RoomKind DecideNextRoomKind(MazeAgent agent, Room fromRoom)
  494. {
  495. // Scoped to fromRoom's own connected component - a different, disconnected branch
  496. // already having an exit room doesn't make this one reachable.
  497. bool componentHasExit = _graph.ComponentHasExit(fromRoom.Id);
  498. bool eligible = agent.NewRoomsVisited >= settings.minNewRoomsBeforeExitEligible;
  499. bool mustForce = agent.NewRoomsVisited >= settings.forceExitAfterNewRooms && !componentHasExit;
  500. // A maxExitRooms cap can block both the safety-valve force and the random roll
  501. // below - once hit, a branch without its own exit has to grow toward and merge
  502. // into an existing one (via TryConnectViaExistingDoor) instead of minting a new
  503. // one. This is what pushes separate maze branches to join up.
  504. bool exitRoomsAvailable = settings.maxExitRooms <= 0 || _graph.ExitRooms.Count < settings.maxExitRooms;
  505. if (mustForce && exitRoomsAvailable) return RoomKind.Exit;
  506. if (eligible && exitRoomsAvailable && UnityEngine.Random.value < settings.exitRoomChanceOnceEligible) return RoomKind.Exit;
  507. if (UnityEngine.Random.value < settings.hallwayChance) return RoomKind.Hallway;
  508. return RoomKind.Normal;
  509. }
  510. private float RadiusFor(RoomKind kind)
  511. {
  512. switch (kind)
  513. {
  514. case RoomKind.Start: return settings.startRoomRadius;
  515. case RoomKind.Hallway: return settings.hallwayRadius;
  516. case RoomKind.Exit: return settings.exitRoomRadius;
  517. default: return settings.normalRoomRadius;
  518. }
  519. }
  520. private float MaxRoomRadius() => Mathf.Max(
  521. Mathf.Max(settings.startRoomRadius, settings.normalRoomRadius),
  522. Mathf.Max(settings.hallwayRadius, settings.exitRoomRadius));
  523. /// <summary>
  524. /// Picks up to `count` distinct cardinal directions (N/E/S/W), excluding the reserved
  525. /// angle (the entry door) if given, in random order. Since a room can only have one
  526. /// exit per side, this returns fewer than `count` if not enough free sides remain.
  527. /// </summary>
  528. private List<float> PickCardinalAngles(int count, float? reservedAngle)
  529. {
  530. var available = new List<float>(CardinalAngles);
  531. if (reservedAngle.HasValue)
  532. available.RemoveAll(a => Mathf.Approximately(NormalizeAngle(a), NormalizeAngle(reservedAngle.Value)));
  533. for (int i = available.Count - 1; i > 0; i--)
  534. {
  535. int j = UnityEngine.Random.Range(0, i + 1);
  536. (available[i], available[j]) = (available[j], available[i]);
  537. }
  538. return available.GetRange(0, Mathf.Min(count, available.Count));
  539. }
  540. private static float NearestCardinal(float angleDegrees)
  541. {
  542. float normalized = NormalizeAngle(angleDegrees);
  543. float best = CardinalAngles[0];
  544. float bestDiff = float.MaxValue;
  545. foreach (var cardinal in CardinalAngles)
  546. {
  547. float diff = Mathf.Abs(Mathf.DeltaAngle(normalized, cardinal));
  548. if (diff < bestDiff)
  549. {
  550. bestDiff = diff;
  551. best = cardinal;
  552. }
  553. }
  554. return best;
  555. }
  556. private static float NormalizeAngle(float angle)
  557. {
  558. angle %= 360f;
  559. if (angle < 0f) angle += 360f;
  560. return angle;
  561. }
  562. private static float AngleOf(Vector2 v) => NormalizeAngle(Mathf.Atan2(v.y, v.x) * Mathf.Rad2Deg);
  563. private static Vector2 DirectionFromAngle(float degrees) =>
  564. new Vector2(Mathf.Cos(degrees * Mathf.Deg2Rad), Mathf.Sin(degrees * Mathf.Deg2Rad));
  565. }
  566. }