MazeGenerator.cs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  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, the candidate room is nudged to a nearby
  48. /// clear spot. Either way, the two-way connection is wired up before returning.
  49. /// </summary>
  50. public Room GenerateConnectedRoom(Room fromRoom, Exit fromExit, MazeAgent agent)
  51. {
  52. if (fromExit.ConnectedRoomId.HasValue)
  53. return _graph.Get(fromExit.ConnectedRoomId.Value);
  54. RoomKind kind = DecideNextRoomKind(agent, fromRoom);
  55. float entryAngle = NormalizeAngle(fromExit.DirectionDegrees + 180f);
  56. Vector2 position = fromRoom.Position + fromExit.Direction * settings.roomSpacing;
  57. float radius = RadiusFor(kind);
  58. Room connectedThroughDoor = TryConnectViaExistingDoor(fromRoom, fromExit, position, radius);
  59. if (connectedThroughDoor != null)
  60. return connectedThroughDoor;
  61. if (HasOverlap(position, radius, fromRoom.Id))
  62. {
  63. if (TryRouteAroundObstruction(fromRoom, fromExit, radius, out Vector2 routedPosition, out Vector2? bendCorner, out float routedEntryAngle))
  64. {
  65. if (bendCorner.HasValue)
  66. return CreateRoomViaBend(fromRoom, fromExit, kind, bendCorner.Value, routedPosition, radius, routedEntryAngle);
  67. position = routedPosition;
  68. entryAngle = routedEntryAngle;
  69. }
  70. else
  71. {
  72. position = FindClearPosition(position, radius, fromRoom.Id);
  73. }
  74. }
  75. return BuildRoom(kind, position, radius, fromRoom.DepthFromStart + 1, fromRoom, fromExit, entryAngle);
  76. }
  77. /// <summary>
  78. /// Creates a room of the given kind at `position`, wires its entry door to
  79. /// `connectingExit` (owned by `connectingRoom`), rolls its extra exits, registers it
  80. /// and fires the generation/connection events. Shared by the plain straight-line
  81. /// spawn path and the bend-routing path (where the "connecting" side is a freshly
  82. /// built elbow hallway rather than fromRoom itself).
  83. /// </summary>
  84. private Room BuildRoom(RoomKind kind, Vector2 position, float radius, int depthFromStart, Room connectingRoom, Exit connectingExit, float entryAngle)
  85. {
  86. var room = new Room(_graph.AllocateRoomId(), kind, position, radius, depthFromStart);
  87. var entryExit = new Exit
  88. {
  89. Id = _graph.AllocateExitId(),
  90. OwnerRoomId = room.Id,
  91. DirectionDegrees = entryAngle
  92. };
  93. _graph.AddExit(room, entryExit);
  94. if (kind != RoomKind.Exit)
  95. {
  96. int extraExits = kind == RoomKind.Hallway
  97. ? 1
  98. : Mathf.Max(0, UnityEngine.Random.Range(settings.minExitsPerRoom, settings.maxExitsPerRoom + 1) - 1);
  99. // Safety net: never let this generation be the one that drains this branch's
  100. // last unused exit before an Exit room exists anywhere in it. Scoped to
  101. // connectingRoom's own connected component - a different, disconnected branch
  102. // already having an exit doesn't help this one.
  103. int projectedPool = _graph.ComponentUnusedExitCount(connectingRoom.Id) - 1 + extraExits;
  104. if (projectedPool <= 0 && !_graph.ComponentHasExit(connectingRoom.Id))
  105. extraExits = Mathf.Max(extraExits, 1);
  106. var extraAngles = PickCardinalAngles(extraExits, entryAngle);
  107. foreach (var angle in extraAngles)
  108. _graph.AddExit(room, new Exit { Id = _graph.AllocateExitId(), OwnerRoomId = room.Id, DirectionDegrees = angle });
  109. }
  110. ConnectExits(connectingExit, entryExit);
  111. _graph.Register(room);
  112. RoomGenerated?.Invoke(room);
  113. RoomsConnected?.Invoke(connectingRoom, connectingExit, room);
  114. return room;
  115. }
  116. /// <summary>
  117. /// Called only once the straight-line spawn spot is known to overlap something and no
  118. /// existing door offered a connection. First tries extending further out along the
  119. /// exit's own direction; if that never clears, tries a single 90-degree bend (to
  120. /// either side) at increasing distances. Each candidate is checked both for a clear
  121. /// footprint and a clear path leading to it. Returns false if nothing within the
  122. /// search range works, in which case the caller falls back to a plain nudge.
  123. /// </summary>
  124. private bool TryRouteAroundObstruction(Room fromRoom, Exit fromExit, float radius,
  125. out Vector2 finalPosition, out Vector2? bendCorner, out float finalEntryAngle)
  126. {
  127. Vector2 dir = fromExit.Direction;
  128. Vector2 edgeFrom = fromRoom.Position + dir * fromRoom.Radius;
  129. float straightEntryAngle = NormalizeAngle(fromExit.DirectionDegrees + 180f);
  130. for (int i = 0; i < settings.maxRepositionRings; i++)
  131. {
  132. float distance = settings.roomSpacing + i * settings.repositionRingStep;
  133. Vector2 candidate = fromRoom.Position + dir * distance;
  134. if (!HasOverlap(candidate, radius, fromRoom.Id) && IsPathClear(edgeFrom, candidate, fromRoom.Id, -1))
  135. {
  136. finalPosition = candidate;
  137. bendCorner = null;
  138. finalEntryAngle = straightEntryAngle;
  139. return true;
  140. }
  141. }
  142. Vector2[] perpendiculars = { new Vector2(-dir.y, dir.x), new Vector2(dir.y, -dir.x) };
  143. for (int i = 0; i < settings.maxRepositionRings; i++)
  144. {
  145. float cornerDistance = settings.roomSpacing + i * settings.repositionRingStep;
  146. Vector2 corner = fromRoom.Position + dir * cornerDistance;
  147. if (!IsPathClear(edgeFrom, corner, fromRoom.Id, -1)) continue;
  148. if (HasOverlap(corner, settings.hallwayRadius, fromRoom.Id)) continue;
  149. foreach (var perp in perpendiculars)
  150. {
  151. for (int j = 0; j < settings.maxRepositionRings; j++)
  152. {
  153. float sideDistance = settings.roomSpacing + j * settings.repositionRingStep;
  154. Vector2 candidate = corner + perp * sideDistance;
  155. if (HasOverlap(candidate, radius, fromRoom.Id)) continue;
  156. if (!IsPathClear(corner, candidate, fromRoom.Id, -1)) continue;
  157. finalPosition = candidate;
  158. bendCorner = corner;
  159. finalEntryAngle = NormalizeAngle(AngleOf(perp) + 180f);
  160. return true;
  161. }
  162. }
  163. }
  164. finalPosition = fromRoom.Position + dir * settings.roomSpacing;
  165. bendCorner = null;
  166. finalEntryAngle = straightEntryAngle;
  167. return false;
  168. }
  169. /// <summary>
  170. /// Builds the elbow hallway room at `corner` connecting fromExit to it, then builds
  171. /// the brand-new destination room beyond the bend and wires the hallway's forward
  172. /// door into it. Returns the hallway - the room fromExit now immediately leads to.
  173. /// </summary>
  174. private Room CreateRoomViaBend(Room fromRoom, Exit fromExit, RoomKind kind, Vector2 corner, Vector2 finalPosition, float radius, float entryAngle)
  175. {
  176. var hallway = new Room(_graph.AllocateRoomId(), RoomKind.Hallway, corner, settings.hallwayRadius, fromRoom.DepthFromStart + 1);
  177. var backExit = new Exit
  178. {
  179. Id = _graph.AllocateExitId(),
  180. OwnerRoomId = hallway.Id,
  181. DirectionDegrees = NormalizeAngle(fromExit.DirectionDegrees + 180f)
  182. };
  183. var forwardExit = new Exit
  184. {
  185. Id = _graph.AllocateExitId(),
  186. OwnerRoomId = hallway.Id,
  187. DirectionDegrees = NormalizeAngle(entryAngle + 180f)
  188. };
  189. _graph.AddExit(hallway, backExit);
  190. _graph.AddExit(hallway, forwardExit);
  191. Vector2 pFrom = fromRoom.Position + fromExit.Direction * fromRoom.Radius;
  192. Vector2 pInto = finalPosition + DirectionFromAngle(entryAngle) * radius;
  193. hallway.CorridorWaypoints = new[] { pFrom, corner, pInto };
  194. ConnectExits(fromExit, backExit);
  195. _graph.Register(hallway);
  196. RoomGenerated?.Invoke(hallway);
  197. RoomsConnected?.Invoke(fromRoom, fromExit, hallway);
  198. BuildRoom(kind, finalPosition, radius, hallway.DepthFromStart + 1, hallway, forwardExit, entryAngle);
  199. return hallway;
  200. }
  201. /// <summary>
  202. /// Looks for an already-placed room whose footprint overlaps the candidate spawn
  203. /// position, on the side that faces fromRoom. Prefers an existing unused exit there;
  204. /// if that side simply has no door on it yet, adds one on the fly instead (not the
  205. /// most "logical" world - the room retroactively grows a doorway - but it closes far
  206. /// more loops than only ever matching pre-existing doors, and keeps rooms from just
  207. /// sitting on top of each other). If one is found (and the resulting path can be
  208. /// routed without crossing anything else), fromExit is wired into that door instead of
  209. /// a brand new room being created. Returns the room fromExit now leads to, or null if
  210. /// no overlapping room offers a usable, reachable door.
  211. /// </summary>
  212. private Room TryConnectViaExistingDoor(Room fromRoom, Exit fromExit, Vector2 candidatePosition, float candidateRadius)
  213. {
  214. float searchRadius = candidateRadius + MaxRoomRadius();
  215. var overlapping = new List<Room>();
  216. foreach (var other in _graph.QueryRoomsNear(candidatePosition, searchRadius))
  217. {
  218. if (other.Id == fromRoom.Id) continue;
  219. float dist = Vector2.Distance(candidatePosition, other.Position);
  220. if (dist > candidateRadius + other.Radius) continue; // footprints don't actually overlap
  221. overlapping.Add(other);
  222. }
  223. overlapping.Sort((a, b) =>
  224. Vector2.Distance(candidatePosition, a.Position).CompareTo(Vector2.Distance(candidatePosition, b.Position)));
  225. foreach (var other in overlapping)
  226. {
  227. Vector2 towardFromRoom = fromRoom.Position - other.Position;
  228. if (towardFromRoom.sqrMagnitude < 0.0001f) continue;
  229. float desiredAngle = NearestCardinal(Mathf.Atan2(towardFromRoom.y, towardFromRoom.x) * Mathf.Rad2Deg);
  230. Exit targetExit = null;
  231. foreach (var exit in other.UnusedExits)
  232. {
  233. if (Mathf.Approximately(NormalizeAngle(exit.DirectionDegrees), desiredAngle))
  234. {
  235. targetExit = exit;
  236. break;
  237. }
  238. }
  239. bool needsNewExit = targetExit == null;
  240. if (needsNewExit)
  241. {
  242. // That side's already spoken for by a different (used) door - can't add
  243. // one here, this room can't be the merge target.
  244. bool sideOccupied = other.Exits.Exists(e => Mathf.Approximately(NormalizeAngle(e.DirectionDegrees), desiredAngle));
  245. if (sideOccupied) continue;
  246. // Respect the configured per-room exit cap even for doors added on the fly.
  247. if (other.Exits.Count >= settings.maxExitsPerRoom) continue;
  248. }
  249. // A connection consumes fromExit (+ targetExit, unless it's a freshly added
  250. // door, which nets to zero). Refuse it if that would leave the merged
  251. // component (fromRoom's + other's, which this connection is about to join)
  252. // with zero unused exits and no Exit room in it - fall back to normal
  253. // generation instead, which always can add fresh exits.
  254. int exitsConsumed = needsNewExit ? 1 : 2;
  255. if (WouldStarveComponent(fromRoom, other, exitsConsumed)) continue;
  256. if (needsNewExit)
  257. {
  258. targetExit = new Exit { Id = _graph.AllocateExitId(), OwnerRoomId = other.Id, DirectionDegrees = desiredAngle };
  259. _graph.AddExit(other, targetExit);
  260. }
  261. Room connected = TryBuildDoorConnection(fromRoom, fromExit, other, targetExit);
  262. if (connected != null)
  263. {
  264. if (needsNewExit) ExitAdded?.Invoke(other, targetExit);
  265. return connected;
  266. }
  267. // Didn't pan out (blocked path) - don't leave a dangling unusable door behind.
  268. if (needsNewExit)
  269. _graph.RemoveUnusedExit(other, targetExit);
  270. }
  271. return null;
  272. }
  273. /// <summary>
  274. /// Builds the actual path between two facing doors: a direct link if they already
  275. /// face each other head-on, or a single 90-degree elbow hallway room if they're on
  276. /// perpendicular sides. Returns null (without mutating anything) if the geometry
  277. /// isn't supported (doors facing the same way) or the path would cross another
  278. /// room/hallway.
  279. /// </summary>
  280. private Room TryBuildDoorConnection(Room fromRoom, Exit fromExit, Room other, Exit targetExit)
  281. {
  282. float d1 = NormalizeAngle(fromExit.DirectionDegrees);
  283. float d2 = NormalizeAngle(targetExit.DirectionDegrees);
  284. float absDiff = Mathf.Abs(Mathf.DeltaAngle(d1, d2));
  285. if (Mathf.Approximately(absDiff, 180f))
  286. return ConnectStraight(fromRoom, fromExit, other, targetExit);
  287. if (Mathf.Approximately(absDiff, 90f))
  288. return ConnectWithElbow(fromRoom, fromExit, other, targetExit);
  289. // Doors facing the same direction would require a U-turn (two bends) - not
  290. // supported by the single-elbow hallway model.
  291. return null;
  292. }
  293. private Room ConnectStraight(Room fromRoom, Exit fromExit, Room other, Exit targetExit)
  294. {
  295. Vector2 pFrom = fromRoom.Position + fromExit.Direction * fromRoom.Radius;
  296. Vector2 pTo = other.Position + targetExit.Direction * other.Radius;
  297. if (!IsPathClear(pFrom, pTo, fromRoom.Id, other.Id)) return null;
  298. ConnectExits(fromExit, targetExit);
  299. RoomsConnected?.Invoke(fromRoom, fromExit, other);
  300. return other;
  301. }
  302. private Room ConnectWithElbow(Room fromRoom, Exit fromExit, Room other, Exit targetExit)
  303. {
  304. Vector2 pFrom = fromRoom.Position + fromExit.Direction * (fromRoom.Radius + settings.hallwayRadius);
  305. Vector2 pTo = other.Position + targetExit.Direction * (other.Radius + settings.hallwayRadius);
  306. bool fromIsHorizontal = Mathf.Abs(fromExit.Direction.x) > 0.5f;
  307. Vector2 corner = fromIsHorizontal ? new Vector2(pTo.x, pFrom.y) : new Vector2(pFrom.x, pTo.y);
  308. // The elbow must travel outward from each door, never doubling back through the
  309. // room it just left or approaching the target door from the wrong side.
  310. if (Vector2.Dot(corner - pFrom, fromExit.Direction) <= 0f) return null;
  311. if (Vector2.Dot(pTo - corner, -targetExit.Direction) <= 0f) return null;
  312. if (!IsPathClear(pFrom, corner, fromRoom.Id, other.Id)) return null;
  313. if (!IsPathClear(corner, pTo, fromRoom.Id, other.Id)) return null;
  314. if (HasOverlap(corner, settings.hallwayRadius, fromRoom.Id, other.Id)) return null;
  315. var hallway = new Room(_graph.AllocateRoomId(), RoomKind.Hallway, corner, settings.hallwayRadius, fromRoom.DepthFromStart + 1);
  316. var backExit = new Exit
  317. {
  318. Id = _graph.AllocateExitId(),
  319. OwnerRoomId = hallway.Id,
  320. DirectionDegrees = NormalizeAngle(fromExit.DirectionDegrees + 180f)
  321. };
  322. var forwardExit = new Exit
  323. {
  324. Id = _graph.AllocateExitId(),
  325. OwnerRoomId = hallway.Id,
  326. DirectionDegrees = NormalizeAngle(targetExit.DirectionDegrees + 180f)
  327. };
  328. _graph.AddExit(hallway, backExit);
  329. _graph.AddExit(hallway, forwardExit);
  330. hallway.CorridorWaypoints = new[] { pFrom, corner, pTo };
  331. ConnectExits(fromExit, backExit);
  332. ConnectExits(forwardExit, targetExit);
  333. _graph.Register(hallway);
  334. RoomGenerated?.Invoke(hallway);
  335. RoomsConnected?.Invoke(fromRoom, fromExit, hallway);
  336. return hallway;
  337. }
  338. /// <summary>True if any registered room (other than those excluded) overlaps a circle at position/radius.</summary>
  339. private bool HasOverlap(Vector2 position, float radius, params int[] excludeRoomIds)
  340. {
  341. float searchRadius = radius + MaxRoomRadius();
  342. foreach (var room in _graph.QueryRoomsNear(position, searchRadius))
  343. {
  344. if (Array.IndexOf(excludeRoomIds, room.Id) >= 0) continue;
  345. if (Vector2.Distance(position, room.Position) <= radius + room.Radius) return true;
  346. }
  347. return false;
  348. }
  349. /// <summary>True if the straight segment a-b stays clear of every room's footprint (other than those excluded), with a small buffer.</summary>
  350. private bool IsPathClear(Vector2 a, Vector2 b, int excludeRoomId1, int excludeRoomId2)
  351. {
  352. Vector2 mid = (a + b) * 0.5f;
  353. float searchRadius = Vector2.Distance(a, b) * 0.5f + MaxRoomRadius() + settings.hallwayRadius;
  354. foreach (var room in _graph.QueryRoomsNear(mid, searchRadius))
  355. {
  356. if (room.Id == excludeRoomId1 || room.Id == excludeRoomId2) continue;
  357. float distToSegment = DistancePointToSegment(room.Position, a, b);
  358. float requiredClearance = room.Radius + settings.hallwayRadius + settings.hallwayClearance;
  359. if (distToSegment < requiredClearance) return false;
  360. }
  361. return true;
  362. }
  363. private static float DistancePointToSegment(Vector2 p, Vector2 a, Vector2 b)
  364. {
  365. Vector2 ab = b - a;
  366. float lengthSq = ab.sqrMagnitude;
  367. if (lengthSq < 0.0001f) return Vector2.Distance(p, a);
  368. float t = Mathf.Clamp01(Vector2.Dot(p - a, ab) / lengthSq);
  369. Vector2 closest = a + ab * t;
  370. return Vector2.Distance(p, closest);
  371. }
  372. /// <summary>
  373. /// Searches outward in a ring pattern around `origin` for the nearest spot where a
  374. /// room of `radius` wouldn't overlap anything already placed, so an overlapping spawn
  375. /// can be nudged aside instead of just sitting on top of another room.
  376. /// </summary>
  377. private Vector2 FindClearPosition(Vector2 origin, float radius, int excludeRoomId)
  378. {
  379. for (int ring = 1; ring <= settings.maxRepositionRings; ring++)
  380. {
  381. float ringRadius = ring * settings.repositionRingStep;
  382. for (int i = 0; i < settings.repositionSamplesPerRing; i++)
  383. {
  384. float angle = (360f / settings.repositionSamplesPerRing) * i;
  385. Vector2 offset = new Vector2(Mathf.Cos(angle * Mathf.Deg2Rad), Mathf.Sin(angle * Mathf.Deg2Rad)) * ringRadius;
  386. Vector2 candidate = origin + offset;
  387. if (!HasOverlap(candidate, radius, excludeRoomId))
  388. return candidate;
  389. }
  390. }
  391. Debug.LogWarning("[MazeGen] Could not find a collision-free spot for an overlapping room; leaving it at its original (overlapping) position.");
  392. return origin;
  393. }
  394. /// <summary>
  395. /// True if connecting fromRoom and other (consuming `exitsConsumed` unused exits in
  396. /// the process) would leave their merged connected component with no unused exits and
  397. /// no Exit room - i.e. a dead branch that could never produce a reachable exit.
  398. /// </summary>
  399. private bool WouldStarveComponent(Room fromRoom, Room other, int exitsConsumed)
  400. {
  401. bool sameComponent = _graph.AreSameComponent(fromRoom.Id, other.Id);
  402. bool mergedHasExit = _graph.ComponentHasExit(fromRoom.Id)
  403. || (!sameComponent && _graph.ComponentHasExit(other.Id));
  404. if (mergedHasExit) return false;
  405. int mergedUnused = _graph.ComponentUnusedExitCount(fromRoom.Id);
  406. if (!sameComponent) mergedUnused += _graph.ComponentUnusedExitCount(other.Id);
  407. return mergedUnused - exitsConsumed < 1;
  408. }
  409. private void ConnectExits(Exit a, Exit b)
  410. {
  411. _graph.MarkExitUsed(a);
  412. _graph.MarkExitUsed(b);
  413. a.ConnectedRoomId = b.OwnerRoomId;
  414. a.ConnectedExitId = b.Id;
  415. b.ConnectedRoomId = a.OwnerRoomId;
  416. b.ConnectedExitId = a.Id;
  417. _graph.Union(a.OwnerRoomId, b.OwnerRoomId);
  418. }
  419. private RoomKind DecideNextRoomKind(MazeAgent agent, Room fromRoom)
  420. {
  421. // Scoped to fromRoom's own connected component - a different, disconnected branch
  422. // already having an exit room doesn't make this one reachable.
  423. bool componentHasExit = _graph.ComponentHasExit(fromRoom.Id);
  424. bool eligible = agent.NewRoomsVisited >= settings.minNewRoomsBeforeExitEligible;
  425. bool mustForce = agent.NewRoomsVisited >= settings.forceExitAfterNewRooms && !componentHasExit;
  426. // A maxExitRooms cap can block both the safety-valve force and the random roll
  427. // below - once hit, a branch without its own exit has to grow toward and merge
  428. // into an existing one (via TryConnectViaExistingDoor) instead of minting a new
  429. // one. This is what pushes separate maze branches to join up.
  430. bool exitRoomsAvailable = settings.maxExitRooms <= 0 || _graph.ExitRooms.Count < settings.maxExitRooms;
  431. if (mustForce && exitRoomsAvailable) return RoomKind.Exit;
  432. if (eligible && exitRoomsAvailable && UnityEngine.Random.value < settings.exitRoomChanceOnceEligible) return RoomKind.Exit;
  433. if (UnityEngine.Random.value < settings.hallwayChance) return RoomKind.Hallway;
  434. return RoomKind.Normal;
  435. }
  436. private float RadiusFor(RoomKind kind)
  437. {
  438. switch (kind)
  439. {
  440. case RoomKind.Start: return settings.startRoomRadius;
  441. case RoomKind.Hallway: return settings.hallwayRadius;
  442. case RoomKind.Exit: return settings.exitRoomRadius;
  443. default: return settings.normalRoomRadius;
  444. }
  445. }
  446. private float MaxRoomRadius() => Mathf.Max(
  447. Mathf.Max(settings.startRoomRadius, settings.normalRoomRadius),
  448. Mathf.Max(settings.hallwayRadius, settings.exitRoomRadius));
  449. /// <summary>
  450. /// Picks up to `count` distinct cardinal directions (N/E/S/W), excluding the reserved
  451. /// angle (the entry door) if given, in random order. Since a room can only have one
  452. /// exit per side, this returns fewer than `count` if not enough free sides remain.
  453. /// </summary>
  454. private List<float> PickCardinalAngles(int count, float? reservedAngle)
  455. {
  456. var available = new List<float>(CardinalAngles);
  457. if (reservedAngle.HasValue)
  458. available.RemoveAll(a => Mathf.Approximately(NormalizeAngle(a), NormalizeAngle(reservedAngle.Value)));
  459. for (int i = available.Count - 1; i > 0; i--)
  460. {
  461. int j = UnityEngine.Random.Range(0, i + 1);
  462. (available[i], available[j]) = (available[j], available[i]);
  463. }
  464. return available.GetRange(0, Mathf.Min(count, available.Count));
  465. }
  466. private static float NearestCardinal(float angleDegrees)
  467. {
  468. float normalized = NormalizeAngle(angleDegrees);
  469. float best = CardinalAngles[0];
  470. float bestDiff = float.MaxValue;
  471. foreach (var cardinal in CardinalAngles)
  472. {
  473. float diff = Mathf.Abs(Mathf.DeltaAngle(normalized, cardinal));
  474. if (diff < bestDiff)
  475. {
  476. bestDiff = diff;
  477. best = cardinal;
  478. }
  479. }
  480. return best;
  481. }
  482. private static float NormalizeAngle(float angle)
  483. {
  484. angle %= 360f;
  485. if (angle < 0f) angle += 360f;
  486. return angle;
  487. }
  488. private static float AngleOf(Vector2 v) => NormalizeAngle(Mathf.Atan2(v.y, v.x) * Mathf.Rad2Deg);
  489. private static Vector2 DirectionFromAngle(float degrees) =>
  490. new Vector2(Mathf.Cos(degrees * Mathf.Deg2Rad), Mathf.Sin(degrees * Mathf.Deg2Rad));
  491. }
  492. }