Pārlūkot izejas kodu

Camera zoom updated, room generation now not spawning over other rooms

Axel Nordh 1 mēnesi atpakaļ
vecāks
revīzija
a1fdeb82d4

+ 23 - 2
Assets/Scenes/SampleScene.unity

@@ -131,6 +131,7 @@ GameObject:
   - component: {fileID: 330585545}
   - component: {fileID: 330585544}
   - component: {fileID: 330585547}
+  - component: {fileID: 330585548}
   m_Layer: 0
   m_Name: Main Camera
   m_TagString: MainCamera
@@ -256,6 +257,26 @@ MonoBehaviour:
     m_VarianceClampScale: 0.9
     m_ContrastAdaptiveSharpening: 0
   m_Version: 2
+--- !u!114 &330585548
+MonoBehaviour:
+  m_ObjectHideFlags: 0
+  m_CorrespondingSourceObject: {fileID: 0}
+  m_PrefabInstance: {fileID: 0}
+  m_PrefabAsset: {fileID: 0}
+  m_GameObject: {fileID: 330585543}
+  m_Enabled: 1
+  m_EditorHideFlags: 0
+  m_Script: {fileID: 11500000, guid: f31d879a768fea642806670a844eab8e, type: 3}
+  m_Name: 
+  m_EditorClassIdentifier: Assembly-CSharp::MazeGen.MazeCameraController
+  panSpeed: 8
+  focusSharpness: 6
+  dragSensitivity: 1
+  zoomSpeed: 150
+  minOrthographicSize: 2
+  maxOrthographicSize: 150
+  minDistance: 5
+  maxDistance: 50
 --- !u!1 &410087039
 GameObject:
   m_ObjectHideFlags: 0
@@ -413,8 +434,8 @@ MonoBehaviour:
   m_Script: {fileID: 11500000, guid: 1f0deb6ed9b595d46a4e43d7c07dbf57, type: 3}
   m_Name: 
   m_EditorClassIdentifier: Assembly-CSharp::MazeGen.MazeSceneBuilder
-  agentCount: 30
-  spawnStaggerRange: {x: 0, y: 1}
+  agentCount: 100
+  spawnStaggerRange: {x: 0, y: 0.1}
   initialDecisionDelayRange: {x: 0.5, y: 2}
   decisionInterval: 1.5
   exploreChance: 0.85

+ 1 - 0
Assets/Scripts/Maze/Agent/AutonomousAgentBrain.cs

@@ -86,6 +86,7 @@ namespace MazeGen
             foreach (var exit in room.Exits)
             {
                 if (exit == reverseExit) continue;
+                if (exit.IsBlocked) continue; // Skip exits that are permanently unusable
 
                 if (!exit.IsUsed)
                 {

+ 5 - 2
Assets/Scripts/Maze/Agent/MazeAgentController.cs

@@ -43,8 +43,9 @@ namespace MazeGen
 
         /// <summary>
         /// Attempts to move the agent through the given exit. Returns false if the exit
-        /// doesn't belong to the agent's current room. Backtracking through an already-used
-        /// exit (moving back to a previously generated room) is allowed.
+        /// doesn't belong to the agent's current room, if the next room couldn't be generated
+        /// (extremely dense packing edge case), or if the exit is blocked. Backtracking through
+        /// an already-used exit (moving back to a previously generated room) is allowed.
         /// </summary>
         public bool TryUseExit(Exit exit)
         {
@@ -55,6 +56,8 @@ namespace MazeGen
                 ? _generator.Graph.Get(exit.ConnectedRoomId.Value)
                 : _generator.GenerateConnectedRoom(Agent.CurrentRoom, exit, Agent);
 
+            if (nextRoom == null) return false; // generation failed or exit is blocked; agent picks again next tick
+
             Agent.EnterRoom(nextRoom, exit);
             return true;
         }

+ 3 - 4
Assets/Scripts/Maze/Agent/MazeGenerationSettings-1.asset

@@ -14,7 +14,7 @@ MonoBehaviour:
   m_EditorClassIdentifier: Assembly-CSharp::MazeGen.MazeGenerationSettings
   minExitsPerRoom: 1
   maxExitsPerRoom: 4
-  startRoomCount: 4
+  startRoomCount: 16
   startRoomSpread: 20
   roomSpacing: 4
   startRoomRadius: 1.2
@@ -24,9 +24,8 @@ MonoBehaviour:
   hallwayChance: 0.25
   hallwayClearance: 0.1
   repositionRingStep: 1
-  repositionSamplesPerRing: 12
-  maxRepositionRings: 10
+  maxRouteSearchSteps: 40
   minNewRoomsBeforeExitEligible: 20
   exitRoomChanceOnceEligible: 0.2
   forceExitAfterNewRooms: 50
-  maxExitRooms: 0
+  maxExitRooms: 5

+ 12 - 9
Assets/Scripts/Maze/Camera/MazeCameraController.cs

@@ -12,10 +12,9 @@ namespace MazeGen
         [SerializeField] private float panSpeed = 8f;
         [SerializeField] private float focusSharpness = 6f;
         [SerializeField] private float dragSensitivity = 1f;
-        [SerializeField] private float zoomSpeed = 1.5f;
+        [SerializeField] private float zoomSpeed = 150f;
         [SerializeField] private float minOrthographicSize = 2f;
-        [SerializeField] private float maxOrthographicSize = 60f;
-
+        [SerializeField] private float maxOrthographicSize = 150f;
         private Camera _camera;
         private Vector2? _focusTarget;
         private Vector2 _lastPointerScreenPos;
@@ -64,20 +63,24 @@ namespace MazeGen
         {
             HandleDrag();
             HandleKeyboardPan();
-            HandleScrollZoom();
+            HandleScrollZoomFOV();
             HandleAutoFocus();
         }
 
-        private void HandleScrollZoom()
+
+        private void HandleScrollZoomFOV()
         {
-            if (_camera == null || !_camera.orthographic) return;
+            if (_camera == null || _camera.orthographic) return;
 
             float scroll = MazeInput.GetScrollDelta();
             if (Mathf.Approximately(scroll, 0f)) return;
 
-            _camera.orthographicSize = Mathf.Clamp(
-                _camera.orthographicSize - scroll * zoomSpeed,
-                minOrthographicSize, maxOrthographicSize);
+            // Decreasing FOV zooms in, increasing FOV zooms out
+            _camera.fieldOfView = Mathf.Clamp(
+                _camera.fieldOfView - (scroll * zoomSpeed),
+                minOrthographicSize,
+                maxOrthographicSize
+            );
         }
 
         private void HandleKeyboardPan()

+ 44 - 0
Assets/Scripts/Maze/Core/CorridorSegment.cs

@@ -0,0 +1,44 @@
+using UnityEngine;
+
+namespace MazeGen
+{
+    /// <summary>
+    /// One straight, axis-aligned leg of a rendered corridor (a consecutive pair from a
+    /// hallway room's CorridorWaypoints), registered so later placements/overlap checks treat
+    /// its full rectangular footprint as occupied space, not just a point at the bend.
+    /// </summary>
+    public class CorridorSegment
+    {
+        public readonly int OwnerRoomId; // the hallway Room.Id this leg belongs to
+        public readonly Vector2 A, B;
+        public readonly float HalfWidth;
+        public readonly Vector2 Min, Max; // precomputed AABB, since legs are guaranteed axis-aligned
+
+        public CorridorSegment(int ownerRoomId, Vector2 a, Vector2 b, float halfWidth)
+        {
+            OwnerRoomId = ownerRoomId;
+            A = a;
+            B = b;
+            HalfWidth = halfWidth;
+
+            float minX = Mathf.Min(a.x, b.x), maxX = Mathf.Max(a.x, b.x);
+            float minY = Mathf.Min(a.y, b.y), maxY = Mathf.Max(a.y, b.y);
+
+            // Pad only in the thin (perpendicular) axis; length axis stays exact so a leg's
+            // AABB doesn't creep past the door it terminates at.
+            if (Mathf.Approximately(minY, maxY))
+            {
+                minY -= halfWidth;
+                maxY += halfWidth;
+            }
+            else
+            {
+                minX -= halfWidth;
+                maxX += halfWidth;
+            }
+
+            Min = new Vector2(minX, minY);
+            Max = new Vector2(maxX, maxY);
+        }
+    }
+}

+ 2 - 0
Assets/Scripts/Maze/Core/CorridorSegment.cs.meta

@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4a4432aa59bda364f9ba4db584154c9a

+ 1 - 0
Assets/Scripts/Maze/Core/Exit.cs

@@ -20,6 +20,7 @@ namespace MazeGen
         public bool IsUsed;
         public int? ConnectedRoomId;
         public int? ConnectedExitId;
+        public bool IsBlocked; // Exit is permanently unusable (e.g., no path could be generated)
 
         public Vector2 Direction => new Vector2(
             Mathf.Cos(DirectionDegrees * Mathf.Deg2Rad),

+ 20 - 0
Assets/Scripts/Maze/Core/MazeGraph.cs

@@ -20,6 +20,7 @@ namespace MazeGen
         public readonly Dictionary<int, Room> Rooms = new Dictionary<int, Room>();
         public readonly List<Room> StartRooms = new List<Room>();
         public readonly List<Room> ExitRooms = new List<Room>();
+        public readonly List<CorridorSegment> CorridorSegments = new List<CorridorSegment>();
 
         private readonly SpatialRoomIndex _spatialIndex;
 
@@ -54,6 +55,13 @@ namespace MazeGen
                 _componentHasExit[Find(room.Id)] = true;
             }
             _spatialIndex.Add(room);
+
+            // Register corridor segments if this is a hallway with waypoints (elbow connector)
+            if (room.Kind == RoomKind.Hallway && room.CorridorWaypoints != null && room.CorridorWaypoints.Length >= 2)
+            {
+                for (int i = 0; i < room.CorridorWaypoints.Length - 1; i++)
+                    CorridorSegments.Add(new CorridorSegment(room.Id, room.CorridorWaypoints[i], room.CorridorWaypoints[i + 1], room.Radius));
+            }
         }
 
         public Room Get(int id) => Rooms.TryGetValue(id, out var room) ? room : null;
@@ -61,6 +69,18 @@ namespace MazeGen
         public IEnumerable<Room> QueryRoomsNear(Vector2 position, float radius) =>
             _spatialIndex.QueryNearby(position, radius);
 
+        /// <summary>Finds corridor segments whose footprint may be near a position/radius (cheap AABB reject).</summary>
+        public IEnumerable<CorridorSegment> QueryCorridorSegmentsNear(Vector2 position, float radius)
+        {
+            foreach (var seg in CorridorSegments)
+            {
+                // Cheap reject: expand seg bounds by radius, test point-in-rect
+                if (position.x + radius < seg.Min.x || position.x - radius > seg.Max.x) continue;
+                if (position.y + radius < seg.Min.y || position.y - radius > seg.Max.y) continue;
+                yield return seg;
+            }
+        }
+
         /// <summary>Adds an exit to a room and keeps the unused-exit counters (global and per-component) in sync.</summary>
         public void AddExit(Room room, Exit exit)
         {

+ 4 - 6
Assets/Scripts/Maze/Generation/MazeGenerationSettings.cs

@@ -31,13 +31,11 @@ namespace MazeGen
         [Tooltip("Extra clearance (world units) required between a candidate hallway path and any unrelated room/hallway footprint for that path to be considered clear.")]
         public float hallwayClearance = 0.1f;
 
-        [Header("Collision avoidance (repositioning)")]
-        [Tooltip("Distance (world units) between successive rings when searching for a collision-free spot to move a newly spawned room to.")]
+        [Header("Collision avoidance (routing)")]
+        [Tooltip("Distance (world units) between successive positions when searching for collision-free paths around obstacles.")]
         public float repositionRingStep = 1f;
-        [Tooltip("Number of candidate positions sampled per ring while searching for a collision-free spot.")]
-        [Range(4, 36)] public int repositionSamplesPerRing = 12;
-        [Tooltip("Maximum number of rings to search outward before giving up and using the original (possibly overlapping) position.")]
-        [Range(1, 32)] public int maxRepositionRings = 10;
+        [Tooltip("Maximum number of search steps in each direction (straight extension and elbow bends) before declaring a spot truly unreachable. Widened from the old maxRepositionRings (was 10), since it's now the sole fallback for all obstruction routing.")]
+        [Range(1, 200)] public int maxRouteSearchSteps = 40;
 
         [Header("Exit-room eligibility")]
         [Tooltip("Number of NEW (never-before-visited) rooms the agent must have entered before an Exit room is allowed to appear.")]

+ 143 - 53
Assets/Scripts/Maze/Generation/MazeGenerator.cs

@@ -57,8 +57,10 @@ namespace MazeGen
         /// checks whether the spawn spot would overlap an existing room that has an unused door
         /// facing back toward fromRoom - if so, connects into that door (straight, or via a
         /// single 90-degree elbow hallway) instead of creating a new, overlapping room. Failing
-        /// that, if the spawn spot overlaps anything, the candidate room is nudged to a nearby
-        /// clear spot. Either way, the two-way connection is wired up before returning.
+        /// that, if the spawn spot overlaps anything, tries routing around it via cardinal
+        /// directions. If all cardinal routing fails, retries TryConnectViaExistingDoor with a
+        /// much wider search radius. If that also fails, marks the exit as blocked and returns
+        /// null (agent will pick a different exit next decision tick).
         /// </summary>
         public Room GenerateConnectedRoom(Room fromRoom, Exit fromExit, MazeAgent agent)
         {
@@ -86,7 +88,16 @@ namespace MazeGen
                 }
                 else
                 {
-                    position = FindClearPosition(position, radius, fromRoom.Id);
+                    // Routing failed - try one more time with a much wider search in TryConnectViaExistingDoor
+                    float extraSearchRadius = settings.maxRouteSearchSteps * settings.repositionRingStep;
+                    Room wideConnected = TryConnectViaExistingDoorWidened(fromRoom, fromExit, position, radius, extraSearchRadius);
+                    if (wideConnected != null)
+                        return wideConnected;
+
+                    // All options exhausted - mark the exit as blocked and refuse to generate
+                    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.");
+                    fromExit.IsBlocked = true;
+                    return null;
                 }
             }
 
@@ -154,11 +165,11 @@ namespace MazeGen
             Vector2 edgeFrom = fromRoom.Position + dir * fromRoom.Radius;
             float straightEntryAngle = NormalizeAngle(fromExit.DirectionDegrees + 180f);
 
-            for (int i = 0; i < settings.maxRepositionRings; i++)
+            for (int i = 0; i < settings.maxRouteSearchSteps; i++)
             {
                 float distance = settings.roomSpacing + i * settings.repositionRingStep;
                 Vector2 candidate = fromRoom.Position + dir * distance;
-                if (!HasOverlap(candidate, radius, fromRoom.Id) && IsPathClear(edgeFrom, candidate, fromRoom.Id, -1))
+                if (!HasOverlap(candidate, radius, fromRoom.Id) && IsPathClear(edgeFrom, candidate, settings.hallwayRadius, fromRoom.Id, -1))
                 {
                     finalPosition = candidate;
                     bendCorner = null;
@@ -168,21 +179,21 @@ namespace MazeGen
             }
 
             Vector2[] perpendiculars = { new Vector2(-dir.y, dir.x), new Vector2(dir.y, -dir.x) };
-            for (int i = 0; i < settings.maxRepositionRings; i++)
+            for (int i = 0; i < settings.maxRouteSearchSteps; i++)
             {
                 float cornerDistance = settings.roomSpacing + i * settings.repositionRingStep;
                 Vector2 corner = fromRoom.Position + dir * cornerDistance;
-                if (!IsPathClear(edgeFrom, corner, fromRoom.Id, -1)) continue;
+                if (!IsPathClear(edgeFrom, corner, settings.hallwayRadius, fromRoom.Id, -1)) continue;
                 if (HasOverlap(corner, settings.hallwayRadius, fromRoom.Id)) continue;
 
                 foreach (var perp in perpendiculars)
                 {
-                    for (int j = 0; j < settings.maxRepositionRings; j++)
+                    for (int j = 0; j < settings.maxRouteSearchSteps; j++)
                     {
                         float sideDistance = settings.roomSpacing + j * settings.repositionRingStep;
                         Vector2 candidate = corner + perp * sideDistance;
                         if (HasOverlap(candidate, radius, fromRoom.Id)) continue;
-                        if (!IsPathClear(corner, candidate, fromRoom.Id, -1)) continue;
+                        if (!IsPathClear(corner, candidate, settings.hallwayRadius, fromRoom.Id, -1)) continue;
 
                         finalPosition = candidate;
                         bendCorner = corner;
@@ -248,6 +259,71 @@ namespace MazeGen
         /// a brand new room being created. Returns the room fromExit now leads to, or null if
         /// no overlapping room offers a usable, reachable door.
         /// </summary>
+        /// <summary>
+        /// Last-resort version of TryConnectViaExistingDoor: searches with a much larger radius
+        /// to find *any* room that can be reached and merged, not just ones overlapping the
+        /// immediate candidate spot.
+        /// </summary>
+        private Room TryConnectViaExistingDoorWidened(Room fromRoom, Exit fromExit, Vector2 candidatePosition, float candidateRadius, float extraSearchRadius)
+        {
+            float searchRadius = candidateRadius + MaxRoomRadius() + extraSearchRadius;
+            var candidates = new List<Room>();
+            foreach (var other in _graph.QueryRoomsNear(fromRoom.Position, searchRadius))
+            {
+                if (other.Id == fromRoom.Id) continue;
+                candidates.Add(other);
+            }
+
+            // Sort by distance to fromRoom position (try nearest first)
+            candidates.Sort((a, b) => Vector2.Distance(fromRoom.Position, a.Position).CompareTo(Vector2.Distance(fromRoom.Position, b.Position)));
+
+            foreach (var other in candidates)
+            {
+                Vector2 towardFromRoom = fromRoom.Position - other.Position;
+                if (towardFromRoom.sqrMagnitude < 0.0001f) continue;
+                float desiredAngle = NearestCardinal(Mathf.Atan2(towardFromRoom.y, towardFromRoom.x) * Mathf.Rad2Deg);
+
+                Exit targetExit = null;
+                foreach (var exit in other.UnusedExits)
+                {
+                    if (Mathf.Approximately(NormalizeAngle(exit.DirectionDegrees), desiredAngle))
+                    {
+                        targetExit = exit;
+                        break;
+                    }
+                }
+
+                bool needsNewExit = targetExit == null;
+                if (needsNewExit)
+                {
+                    bool sideOccupied = other.Exits.Exists(e => Mathf.Approximately(NormalizeAngle(e.DirectionDegrees), desiredAngle));
+                    if (sideOccupied) continue;
+                    if (other.Exits.Count >= settings.maxExitsPerRoom) continue;
+                }
+
+                int exitsConsumed = needsNewExit ? 1 : 2;
+                if (WouldStarveComponent(fromRoom, other, exitsConsumed)) continue;
+
+                if (needsNewExit)
+                {
+                    targetExit = new Exit { Id = _graph.AllocateExitId(), OwnerRoomId = other.Id, DirectionDegrees = desiredAngle };
+                    _graph.AddExit(other, targetExit);
+                }
+
+                Room connected = TryBuildDoorConnection(fromRoom, fromExit, other, targetExit);
+                if (connected != null)
+                {
+                    if (needsNewExit) ExitAdded?.Invoke(other, targetExit);
+                    return connected;
+                }
+
+                if (needsNewExit)
+                    _graph.RemoveUnusedExit(other, targetExit);
+            }
+
+            return null;
+        }
+
         private Room TryConnectViaExistingDoor(Room fromRoom, Exit fromExit, Vector2 candidatePosition, float candidateRadius)
         {
             float searchRadius = candidateRadius + MaxRoomRadius();
@@ -255,8 +331,7 @@ namespace MazeGen
             foreach (var other in _graph.QueryRoomsNear(candidatePosition, searchRadius))
             {
                 if (other.Id == fromRoom.Id) continue;
-                float dist = Vector2.Distance(candidatePosition, other.Position);
-                if (dist > candidateRadius + other.Radius) continue; // footprints don't actually overlap
+                if (!SquaresOverlap(candidatePosition, candidateRadius, other.Position, other.Radius)) continue;
                 overlapping.Add(other);
             }
 
@@ -346,9 +421,16 @@ namespace MazeGen
 
         private Room ConnectStraight(Room fromRoom, Exit fromExit, Room other, Exit targetExit)
         {
+            // Defensive check: the two rooms shouldn't already overlap
+            if (SquaresOverlap(fromRoom.Position, fromRoom.Radius, other.Position, other.Radius))
+            {
+                Debug.LogWarning($"[MazeGen] ConnectStraight refused: room {fromRoom.Id} and room {other.Id} already overlap.");
+                return null;
+            }
+
             Vector2 pFrom = fromRoom.Position + fromExit.Direction * fromRoom.Radius;
             Vector2 pTo = other.Position + targetExit.Direction * other.Radius;
-            if (!IsPathClear(pFrom, pTo, fromRoom.Id, other.Id)) return null;
+            if (!IsPathClear(pFrom, pTo, settings.hallwayRadius, fromRoom.Id, other.Id)) return null;
 
             ConnectExits(fromExit, targetExit);
             RoomsConnected?.Invoke(fromRoom, fromExit, other);
@@ -368,8 +450,8 @@ namespace MazeGen
             if (Vector2.Dot(corner - pFrom, fromExit.Direction) <= 0f) return null;
             if (Vector2.Dot(pTo - corner, -targetExit.Direction) <= 0f) return null;
 
-            if (!IsPathClear(pFrom, corner, fromRoom.Id, other.Id)) return null;
-            if (!IsPathClear(corner, pTo, fromRoom.Id, other.Id)) return null;
+            if (!IsPathClear(pFrom, corner, settings.hallwayRadius, fromRoom.Id, other.Id)) return null;
+            if (!IsPathClear(corner, pTo, settings.hallwayRadius, fromRoom.Id, other.Id)) return null;
             if (HasOverlap(corner, settings.hallwayRadius, fromRoom.Id, other.Id)) return null;
 
             var hallway = new Room(_graph.AllocateRoomId(), RoomKind.Hallway, corner, settings.hallwayRadius, fromRoom.DepthFromStart + 1);
@@ -399,68 +481,76 @@ namespace MazeGen
             return hallway;
         }
 
-        /// <summary>True if any registered room (other than those excluded) overlaps a circle at position/radius.</summary>
+        /// <summary>True if any registered room or corridor segment (other than those excluded) overlaps a square at position/radius.</summary>
         private bool HasOverlap(Vector2 position, float radius, params int[] excludeRoomIds)
         {
             float searchRadius = radius + MaxRoomRadius();
             foreach (var room in _graph.QueryRoomsNear(position, searchRadius))
             {
                 if (Array.IndexOf(excludeRoomIds, room.Id) >= 0) continue;
-                if (Vector2.Distance(position, room.Position) <= radius + room.Radius) return true;
+                if (SquaresOverlap(position, radius, room.Position, room.Radius)) return true;
+            }
+            foreach (var seg in _graph.QueryCorridorSegmentsNear(position, radius))
+            {
+                if (Array.IndexOf(excludeRoomIds, seg.OwnerRoomId) >= 0) continue;
+                if (SquareOverlapsRect(position, radius, seg.Min, seg.Max)) return true;
             }
             return false;
         }
 
-        /// <summary>True if the straight segment a-b stays clear of every room's footprint (other than those excluded), with a small buffer.</summary>
-        private bool IsPathClear(Vector2 a, Vector2 b, int excludeRoomId1, int excludeRoomId2)
+        /// <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>
+        private bool IsPathClear(Vector2 a, Vector2 b, float halfWidth, int excludeRoomId1, int excludeRoomId2)
         {
+            Vector2 min = new Vector2(Mathf.Min(a.x, b.x), Mathf.Min(a.y, b.y));
+            Vector2 max = new Vector2(Mathf.Max(a.x, b.x), Mathf.Max(a.y, b.y));
+
+            // Pad only in the thin (perpendicular) axis
+            if (Mathf.Approximately(min.y, max.y))
+            {
+                min.y -= halfWidth;
+                max.y += halfWidth;
+            }
+            else
+            {
+                min.x -= halfWidth;
+                max.x += halfWidth;
+            }
+
+            // Add clearance buffer
+            min -= Vector2.one * settings.hallwayClearance;
+            max += Vector2.one * settings.hallwayClearance;
+
             Vector2 mid = (a + b) * 0.5f;
-            float searchRadius = Vector2.Distance(a, b) * 0.5f + MaxRoomRadius() + settings.hallwayRadius;
+            float searchRadius = Vector2.Distance(a, b) * 0.5f + MaxRoomRadius() + halfWidth + settings.hallwayClearance;
 
             foreach (var room in _graph.QueryRoomsNear(mid, searchRadius))
             {
                 if (room.Id == excludeRoomId1 || room.Id == excludeRoomId2) continue;
-                float distToSegment = DistancePointToSegment(room.Position, a, b);
-                float requiredClearance = room.Radius + settings.hallwayRadius + settings.hallwayClearance;
-                if (distToSegment < requiredClearance) return false;
+                if (SquareOverlapsRect(room.Position, room.Radius, min, max)) return false;
+            }
+            foreach (var seg in _graph.QueryCorridorSegmentsNear(mid, searchRadius))
+            {
+                if (seg.OwnerRoomId == excludeRoomId1 || seg.OwnerRoomId == excludeRoomId2) continue;
+                if (RectsOverlap(min, max, seg.Min, seg.Max)) return false;
             }
             return true;
         }
 
-        private static float DistancePointToSegment(Vector2 p, Vector2 a, Vector2 b)
-        {
-            Vector2 ab = b - a;
-            float lengthSq = ab.sqrMagnitude;
-            if (lengthSq < 0.0001f) return Vector2.Distance(p, a);
+        // AABB overlap primitives (axis-aligned, since all directions are cardinal)
 
-            float t = Mathf.Clamp01(Vector2.Dot(p - a, ab) / lengthSq);
-            Vector2 closest = a + ab * t;
-            return Vector2.Distance(p, closest);
-        }
+        /// <summary>True if two axis-aligned squares (centers posA/posB, half-sides rA/rB) overlap.</summary>
+        private static bool SquaresOverlap(Vector2 posA, float rA, Vector2 posB, float rB) =>
+            Mathf.Abs(posA.x - posB.x) < rA + rB && Mathf.Abs(posA.y - posB.y) < rA + rB;
 
-        /// <summary>
-        /// Searches outward in a ring pattern around `origin` for the nearest spot where a
-        /// room of `radius` wouldn't overlap anything already placed, so an overlapping spawn
-        /// can be nudged aside instead of just sitting on top of another room.
-        /// </summary>
-        private Vector2 FindClearPosition(Vector2 origin, float radius, int excludeRoomId)
-        {
-            for (int ring = 1; ring <= settings.maxRepositionRings; ring++)
-            {
-                float ringRadius = ring * settings.repositionRingStep;
-                for (int i = 0; i < settings.repositionSamplesPerRing; i++)
-                {
-                    float angle = (360f / settings.repositionSamplesPerRing) * i;
-                    Vector2 offset = new Vector2(Mathf.Cos(angle * Mathf.Deg2Rad), Mathf.Sin(angle * Mathf.Deg2Rad)) * ringRadius;
-                    Vector2 candidate = origin + offset;
-                    if (!HasOverlap(candidate, radius, excludeRoomId))
-                        return candidate;
-                }
-            }
+        /// <summary>True if an axis-aligned square (pos/radius) overlaps an axis-aligned rect [min,max].</summary>
+        private static bool SquareOverlapsRect(Vector2 pos, float radius, Vector2 rectMin, Vector2 rectMax) =>
+            pos.x + radius > rectMin.x && pos.x - radius < rectMax.x &&
+            pos.y + radius > rectMin.y && pos.y - radius < rectMax.y;
+
+        /// <summary>True if two axis-aligned rects overlap.</summary>
+        private static bool RectsOverlap(Vector2 aMin, Vector2 aMax, Vector2 bMin, Vector2 bMax) =>
+            aMin.x < bMax.x && aMax.x > bMin.x && aMin.y < bMax.y && aMax.y > bMin.y;
 
-            Debug.LogWarning("[MazeGen] Could not find a collision-free spot for an overlapping room; leaving it at its original (overlapping) position.");
-            return origin;
-        }
 
         /// <summary>
         /// True if connecting fromRoom and other (consuming `exitsConsumed` unused exits in

+ 29 - 5
Assets/Scripts/Maze/Visualization/MazeSceneBuilder.cs

@@ -82,7 +82,11 @@ namespace MazeGen
             }
 
             _cameraController?.FrameBounds(ComputeMazeBounds());
-            StartCoroutine(SpawnAgentsRoutine(startRooms));
+
+            // Assign each agent to a start room, then spawn per-room with independent stagger
+            var assignments = AssignAgentsToStartRooms(agentCount, startRooms.Count);
+            for (int r = 0; r < startRooms.Count; r++)
+                StartCoroutine(SpawnAgentsForRoomRoutine(startRooms[r], assignments[r]));
         }
 
         /// <summary>
@@ -113,15 +117,35 @@ namespace MazeGen
             return Random.insideUnitCircle * spread; // give up avoiding overlap, use whatever we last rolled
         }
 
-        private IEnumerator SpawnAgentsRoutine(List<Room> startRooms)
+        /// <summary>
+        /// Randomly assigns each agent index (0..agentCount-1) to one of the start rooms, decided
+        /// upfront before any staggering/spawning begins, so the same statistical distribution as
+        /// today's "pick a random room per spawn" applies, but grouped so each room's agents can be
+        /// staggered independently and concurrently with other rooms.
+        /// </summary>
+        private static List<int>[] AssignAgentsToStartRooms(int agentCount, int roomCount)
         {
+            var result = new List<int>[roomCount];
+            for (int r = 0; r < roomCount; r++)
+                result[r] = new List<int>();
             for (int i = 0; i < agentCount; i++)
+                result[Random.Range(0, roomCount)].Add(i);
+            return result;
+        }
+
+        /// <summary>
+        /// One independent instance of this runs per start room, all started concurrently from
+        /// Start(). Staggers only the agents assigned to THIS room by spawnStaggerRange, so agents
+        /// in different start rooms can spawn at the same wall-clock time while agents sharing a
+        /// room are still staggered relative to each other, same as before.
+        /// </summary>
+        private IEnumerator SpawnAgentsForRoomRoutine(Room startRoom, List<int> agentIndices)
+        {
+            foreach (int index in agentIndices)
             {
                 float delay = Random.Range(spawnStaggerRange.x, spawnStaggerRange.y);
                 if (delay > 0f) yield return new WaitForSeconds(delay);
-
-                Room startRoom = startRooms[Random.Range(0, startRooms.Count)];
-                SpawnAgent(startRoom, i);
+                SpawnAgent(startRoom, index);
             }
         }
 

+ 28 - 27
UserSettings/Layouts/default-6000.dwlt

@@ -19,7 +19,7 @@ MonoBehaviour:
     width: 3440
     height: 1349
   m_ShowMode: 4
-  m_Title: Hierarchy
+  m_Title: Game
   m_RootView: {fileID: 2}
   m_MinSize: {x: 875, y: 300}
   m_MaxSize: {x: 10000, y: 10000}
@@ -119,7 +119,7 @@ MonoBehaviour:
   m_MinSize: {x: 400, y: 112}
   m_MaxSize: {x: 32384, y: 16192}
   vertical: 0
-  controlID: 22172
+  controlID: 48
   draggingID: 0
 --- !u!114 &6
 MonoBehaviour:
@@ -145,7 +145,7 @@ MonoBehaviour:
   m_MinSize: {x: 300, y: 112}
   m_MaxSize: {x: 24288, y: 16192}
   vertical: 1
-  controlID: 22173
+  controlID: 49
   draggingID: 0
 --- !u!114 &7
 MonoBehaviour:
@@ -172,7 +172,7 @@ MonoBehaviour:
   m_MinSize: {x: 300, y: 56}
   m_MaxSize: {x: 24288, y: 8096}
   vertical: 0
-  controlID: 22174
+  controlID: 50
   draggingID: 0
 --- !u!114 &8
 MonoBehaviour:
@@ -279,7 +279,7 @@ MonoBehaviour:
   m_MinSize: {x: 200, y: 56}
   m_MaxSize: {x: 16192, y: 8096}
   vertical: 0
-  controlID: 22303
+  controlID: 125
   draggingID: 0
 --- !u!114 &12
 MonoBehaviour:
@@ -773,11 +773,12 @@ MonoBehaviour:
       m_LastClickedID:
         m_Data: 0
       m_ExpandedIDs:
-      - m_Data: -150104
-      - m_Data: -150096
-      - m_Data: -68256
-      - m_Data: -68248
+      - m_Data: -818808
+      - m_Data: -818668
+      - m_Data: -451282
+      - m_Data: -451274
       - m_Data: -1298
+      - m_Data: 45798
       m_RenameOverlay:
         m_UserAcceptedRename: 0
         m_Name: 
@@ -1440,9 +1441,9 @@ MonoBehaviour:
   m_AudioPlay: 0
   m_DebugDrawModesUseInteractiveLightBakingData: 0
   m_Position:
-    m_Target: {x: -16.798956, y: 4.9354935, z: -48.153038}
+    m_Target: {x: 1.3210471, y: 1.6667428, z: -0.3746831}
     speed: 2
-    m_Value: {x: -16.798956, y: 4.9354935, z: -48.153038}
+    m_Value: {x: 1.3210471, y: 1.6667428, z: -0.3746831}
   m_RenderMode: 0
   m_CameraMode:
     drawMode: 0
@@ -1488,13 +1489,13 @@ MonoBehaviour:
     m_GridAxis: 1
     m_gridOpacity: 0.5
   m_Rotation:
-    m_Target: {x: -0.00019804298, y: 0.9997966, z: -0.016807096, w: -0.011696933}
+    m_Target: {x: 0.00044787914, y: 0.99879736, z: -0.048288397, w: 0.0092900805}
     speed: 2
-    m_Value: {x: -0.00019804298, y: 0.9997966, z: -0.016807096, w: -0.011696933}
+    m_Value: {x: 0.00044787914, y: 0.99879736, z: -0.048288397, w: 0.0092900805}
   m_Size:
-    m_Target: 67.092354
+    m_Target: 3.0185056
     speed: 2
-    m_Value: 67.092354
+    m_Value: 3.0185056
   m_Ortho:
     m_Target: 0
     speed: 2
@@ -1623,7 +1624,7 @@ MonoBehaviour:
     m_DynamicPanelBehavior: 0
   m_LockTracker:
     m_IsLocked: 0
-  m_LastSelectedObjectID: 0
+  m_LastSelectedObjectID: 45780
 --- !u!114 &21
 MonoBehaviour:
   m_ObjectHideFlags: 52
@@ -1728,16 +1729,16 @@ MonoBehaviour:
     m_IsLocked: 0
   m_LastLocalAssetsSearchArea: 0
   m_FolderTreeState:
-    scrollPos: {x: 0, y: 44}
+    scrollPos: {x: 0, y: 12}
     m_SelectedIDs:
-    - m_Data: 46148
+    - m_Data: 46194
     m_LastClickedID:
-      m_Data: 46148
+      m_Data: 46194
     m_ExpandedIDs:
     - m_Data: 0
-    - m_Data: 45876
-    - m_Data: 45878
-    - m_Data: 45880
+    - m_Data: 45882
+    - m_Data: 45884
+    - m_Data: 45886
     m_RenameOverlay:
       m_UserAcceptedRename: 0
       m_Name: 
@@ -1770,9 +1771,9 @@ MonoBehaviour:
       m_Data: 0
     m_ExpandedIDs:
     - m_Data: 0
-    - m_Data: 45876
-    - m_Data: 45878
-    - m_Data: 45880
+    - m_Data: 45882
+    - m_Data: 45884
+    - m_Data: 45886
     m_RenameOverlay:
       m_UserAcceptedRename: 0
       m_Name: 
@@ -1800,8 +1801,8 @@ MonoBehaviour:
       m_ResourceFile: 
   m_ListAreaState:
     m_SelectedInstanceIDs:
-    - m_Data: -2678
-    m_LastClickedInstanceID: -2678
+    - m_Data: 45780
+    m_LastClickedInstanceID: 45780
     m_HadKeyboardFocusLastEvent: 1
     m_ExpandedInstanceIDs:
     - m_Data: 70016