MazeGenerator.cs 25 KB

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