MazeGenerator.cs 33 KB

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