World/ProceduralDungeon.cs
using System;
using System.Collections.Generic;
using System.Linq;

namespace Prototype;

public sealed class ProceduralDungeon : Component
{
	// Twice the width and depth of the original rooms: four times the floor area.
	public const float RoomSpacing = 2640f;
	public const float RoomHalfSize = 1120f;
	public const float RoomSize = RoomHalfSize * 2f;
	public const float ConnectorLength = RoomSpacing - RoomSize;
	public const float BarrierHeight = 640f;
	private const float EntranceSealClearance = 460f;
	public IReadOnlyList<RoomCell> Rooms => _rooms;
	public IEnumerable<RoomLink> Links
	{
		get { for ( var i = 0; i < _rooms.Count - 1; i++ ) yield return new RoomLink( _rooms[i], _rooms[i + 1] ); }
	}
	public Vector3 CurrentCenter => _rooms.Count == 0 ? Vector3.Zero : _rooms[Math.Clamp( ActiveRoom, 0, _rooms.Count - 1 )].World( RoomSpacing );
	public IEnumerable<ObstacleMapItem> Obstacles
	{
		get
		{
			foreach ( var pair in _obstacles )
			foreach ( var obstacle in pair.Value )
				yield return new ObstacleMapItem(
					_rooms[pair.Key].World( RoomSpacing ) + obstacle.Center,
					obstacle.HalfSize * 2f,
					obstacle.IsWall );
		}
	}
	public int ActiveRoom { get; private set; }

	private readonly List<RoomCell> _rooms = new();
	private readonly List<GameObject> _gates = new();
	private readonly Dictionary<int, List<ObstacleBounds>> _obstacles = new();
	private GameObject _geometry;
	private int _pendingEntrance = -1;

	public void Generate( int seed, int roomCount )
	{
		_geometry?.Destroy();
		_geometry = new GameObject( true, "Generated Run" );
		_geometry.NetworkMode = NetworkMode.Never;
		_rooms.Clear();
		_gates.Clear();
		_obstacles.Clear();
		ActiveRoom = 0;
		_pendingEntrance = -1;
		var rng = new Random( seed );
		var cell = new RoomCell( 0, 0 );
		_rooms.Add( cell );
		for ( var i = 1; i < roomCount; i++ )
		{
			var candidates = new[]
			{
				new RoomCell( cell.X + 1, cell.Y ), new RoomCell( cell.X, cell.Y + 1 ),
				new RoomCell( cell.X, cell.Y - 1 ), new RoomCell( cell.X - 1, cell.Y )
			}.OrderBy( _ => rng.Next() );
			cell = candidates.FirstOrDefault( candidate => !_rooms.Contains( candidate ) );
			if ( _rooms.Contains( cell ) ) cell = new RoomCell( i, 0 );
			_rooms.Add( cell );
		}

		for ( var i = 0; i < _rooms.Count; i++ ) BuildRoom( _rooms[i], i, rng );
		for ( var i = 0; i < _rooms.Count - 1; i++ ) BuildGate( _rooms[i], _rooms[i + 1], i );
		var minX = _rooms.Min( room => room.World( RoomSpacing ).x );
		var maxX = _rooms.Max( room => room.World( RoomSpacing ).x );
		var minY = _rooms.Min( room => room.World( RoomSpacing ).y );
		var maxY = _rooms.Max( room => room.World( RoomSpacing ).y );
		var skyCenter = new Vector3( (minX + maxX) * .5f, (minY + maxY) * .5f, 0f );
		var skyRadius = MathF.Max( maxX - minX, maxY - minY ) * .5f + RoomHalfSize + 900f;
		RuinVisualFactory.CreateSkyBackdrop( _geometry, skyCenter, skyRadius );
	}

	private void BuildGate( RoomCell from, RoomCell to, int index )
	{
		var fromCenter = from.World( RoomSpacing );
		var toCenter = to.World( RoomSpacing );
		var direction = (toCenter - fromCenter).Normal;
		var midpoint = (fromCenter + toCenter) * .5f + Vector3.Up * (BarrierHeight * .5f);
		var size = MathF.Abs( direction.x ) > .5f
			? new Vector3( 42f, 460f, BarrierHeight )
			: new Vector3( 460f, 42f, BarrierHeight );
		var gate = PrototypeFactory.Box( $"Combat Gate {index + 1}", midpoint, size, new Color( .08f, .72f, .92f ), true, _geometry );
		// The gate is only its energy slab; no temporary pillars or capitals.
		gate.Tags.Add( "combat_gate" );
		_gates.Add( gate );

		// A short enclosed connector prevents players from walking around a gate
		// through the gap between two independently generated room shells.
		if ( MathF.Abs( direction.x ) > .5f )
		{
			var bridge = PrototypeFactory.Box( "Connector Floor", midpoint.WithZ( -18f ), new Vector3( ConnectorLength, 460f, 32f ), new Color( .075f, .1f, .15f ), true, _geometry );
			RuinVisualFactory.DecorateBridge( bridge, new Vector3( ConnectorLength, 460f, 32f ) );
			PrototypeFactory.Box( "Connector Wall", midpoint + Vector3.Left * 230f, new Vector3( ConnectorLength, 32f, BarrierHeight ), new Color( .16f, .21f, .28f ), true, _geometry );
			PrototypeFactory.Box( "Connector Wall", midpoint + Vector3.Right * 230f, new Vector3( ConnectorLength, 32f, BarrierHeight ), new Color( .16f, .21f, .28f ), true, _geometry );
			PrototypeFactory.Box( "Gap Blocker", midpoint + Vector3.Left * 675f, new Vector3( ConnectorLength, 890f, BarrierHeight ), new Color( .12f, .17f, .24f ), true, _geometry );
			PrototypeFactory.Box( "Gap Blocker", midpoint + Vector3.Right * 675f, new Vector3( ConnectorLength, 890f, BarrierHeight ), new Color( .12f, .17f, .24f ), true, _geometry );
		}
		else
		{
			var bridge = PrototypeFactory.Box( "Connector Floor", midpoint.WithZ( -18f ), new Vector3( 460f, ConnectorLength, 32f ), new Color( .075f, .1f, .15f ), true, _geometry );
			RuinVisualFactory.DecorateBridge( bridge, new Vector3( 460f, ConnectorLength, 32f ) );
			PrototypeFactory.Box( "Connector Wall", midpoint + Vector3.Backward * 230f, new Vector3( 32f, ConnectorLength, BarrierHeight ), new Color( .16f, .21f, .28f ), true, _geometry );
			PrototypeFactory.Box( "Connector Wall", midpoint + Vector3.Forward * 230f, new Vector3( 32f, ConnectorLength, BarrierHeight ), new Color( .16f, .21f, .28f ), true, _geometry );
			PrototypeFactory.Box( "Gap Blocker", midpoint + Vector3.Backward * 675f, new Vector3( 890f, ConnectorLength, BarrierHeight ), new Color( .12f, .17f, .24f ), true, _geometry );
			PrototypeFactory.Box( "Gap Blocker", midpoint + Vector3.Forward * 675f, new Vector3( 890f, ConnectorLength, BarrierHeight ), new Color( .12f, .17f, .24f ), true, _geometry );
		}
	}

	private void BuildRoom( RoomCell cell, int index, Random rng )
	{
		var center = cell.World( RoomSpacing );
		var roomObstacles = new List<ObstacleBounds>();
		_obstacles[index] = roomObstacles;
		var floorColor = index == 0 ? new Color( .08f, .18f, .24f ) : new Color( .1f + index * .01f, .1f, .17f );
		var floor = PrototypeFactory.Box( $"Room {index + 1} Floor", center + Vector3.Down * 18f, new Vector3( RoomSize, RoomSize, 32 ), floorColor, true, _geometry );
		RuinVisualFactory.DecorateFloor( floor, new Vector3( RoomSize, RoomSize, 32 ), index );
		RuinVisualFactory.CreateRoomTorches( _geometry, center, RoomHalfSize, index );

		var openings = new HashSet<(int,int)>();
		foreach ( var other in new[] { index > 0 ? _rooms[index - 1] : cell, index < _rooms.Count - 1 ? _rooms[index + 1] : cell } )
		{
			var dx = other.X - cell.X; var dy = other.Y - cell.Y;
			if ( Math.Abs( dx ) + Math.Abs( dy ) == 1 ) openings.Add( (dx, dy) );
		}
		Wall( center + new Vector3( 0, RoomHalfSize, BarrierHeight * .5f ), new Vector3( RoomSize, 38, BarrierHeight ), openings.Contains( (0,1) ), center, roomObstacles );
		Wall( center + new Vector3( 0, -RoomHalfSize, BarrierHeight * .5f ), new Vector3( RoomSize, 38, BarrierHeight ), openings.Contains( (0,-1) ), center, roomObstacles );
		Wall( center + new Vector3( RoomHalfSize, 0, BarrierHeight * .5f ), new Vector3( 38, RoomSize, BarrierHeight ), openings.Contains( (1,0) ), center, roomObstacles );
		Wall( center + new Vector3( -RoomHalfSize, 0, BarrierHeight * .5f ), new Vector3( 38, RoomSize, BarrierHeight ), openings.Contains( (-1,0) ), center, roomObstacles );
		if ( index == 0 ) return;
		var layout = rng.Next( 0, 8 );
		var coverCount = rng.Next( 8, 14 );
		for ( var c = 0; c < coverCount; c++ )
		{
			var placed = false;
			for ( var attempt = 0; attempt < 90 && !placed; attempt++ )
			{
				var candidate = CreateObstacleCandidate( layout, c, attempt, rng );
				var size = candidate.Size;
				var offset = candidate.Offset.WithZ( size.z * .5f );
				// Cubes and long walls share the same exact AABB representation. This is
				// the authoritative geometry for placement, spawning, AI and the minimap.
				var bounds = new ObstacleBounds( offset.WithZ( 0f ), size.WithZ( 0f ) * .5f, candidate.IsWall );
				// Never create a slot beside a perimeter wall that an agent can enter but
				// cannot traverse. Keep a genuine navigation lane around every cover piece.
				const float minimumWallLane = 380f;
				if ( MathF.Abs( offset.x ) + bounds.HalfSize.x > RoomHalfSize - minimumWallLane ) continue;
				if ( MathF.Abs( offset.y ) + bounds.HalfSize.y > RoomHalfSize - minimumWallLane ) continue;
				if ( offset.WithZ( 0f ).Length < 280f ) continue;
				// Preserve a broad route parallel to every long wall. Blocks stay in the
				// four cover sectors; walls may cross one axis, but can always be rounded
				// at both ends thanks to minimumWallLane.
				if ( candidate.IsWall )
				{
					var horizontalWall = bounds.HalfSize.x > bounds.HalfSize.y;
					if ( horizontalWall && MathF.Abs( offset.y ) < 260f + bounds.HalfSize.y ) continue;
					if ( !horizontalWall && MathF.Abs( offset.x ) < 260f + bounds.HalfSize.x ) continue;
				}
				else if ( MathF.Abs( offset.x ) < 260f + bounds.HalfSize.x || MathF.Abs( offset.y ) < 260f + bounds.HalfSize.y ) continue;
				if ( roomObstacles.Any( other => bounds.Overlaps( other, 190f ) ) ) continue;
				roomObstacles.Add( bounds );
				var color = candidate.IsWall ? new Color( .16f, .23f, .31f ) : new Color( .22f, .28f, .36f );
				var obstacle = PrototypeFactory.Box( candidate.IsWall ? "Interior Wall" : "Cover Block", center + offset, size, color, true, _geometry );
				RuinVisualFactory.DecorateObstacle( obstacle, size, candidate.IsWall, c, (p, dimensions) => roomObstacles.Add( new ObstacleBounds( (p - center).WithZ(0), dimensions.WithZ(0) * .5f, false ) ) );
				placed = true;
			}
		}
	}

	private static ObstacleCandidate CreateObstacleCandidate( int layout, int index, int attempt, Random rng )
	{
		var height = rng.Next( 100, 230 );
		var wall = layout is 1 or 2 or 5 || (layout is 3 or 6 or 7 && (index + attempt) % 2 == 0);
		var horizontal = layout == 1 || (layout is 3 or 5 or 7 && (index + attempt) % 4 < 2);
		Vector3 size;
		if ( wall )
		{
			var length = rng.Next( 320, 610 );
			var thickness = rng.Next( 70, 121 );
			size = horizontal ? new Vector3( length, thickness, height ) : new Vector3( thickness, length, height );
		}
		else
		{
			var width = layout == 4 ? rng.Next( 120, 180 ) : rng.Next( 110, 230 );
			size = new Vector3( width, rng.Next( 110, 230 ), height );
		}

		var x = rng.Next( -690, 691 );
		var y = rng.Next( -690, 691 );
		switch ( layout )
		{
			case 1: y = (index % 2 == 0 ? -1 : 1) * rng.Next( 390, 650 ); break;
			case 2: x = (index % 2 == 0 ? -1 : 1) * rng.Next( 390, 650 ); break;
			case 3:
				x = (index % 2 == 0 ? -1 : 1) * rng.Next( 380, 680 );
				y = ((index / 2) % 2 == 0 ? -1 : 1) * rng.Next( 380, 680 );
				break;
			case 4:
				x = (index % 2 == 0 ? -1 : 1) * rng.Next( 410, 650 );
				y = ((index / 2) % 2 == 0 ? -1 : 1) * rng.Next( 410, 650 );
				break;
			case 5: if ( index % 2 == 0 ) x = (index % 4 == 0 ? -1 : 1) * rng.Next( 430, 660 ); else y = (index % 4 == 1 ? -1 : 1) * rng.Next( 430, 660 ); break;
			case 6:
				x = (index % 2 == 0 ? -1 : 1) * rng.Next( 400, 670 );
				y = ((index / 2) % 2 == 0 ? -1 : 1) * rng.Next( 360, 650 );
				break;
		}
		return new ObstacleCandidate( new Vector3( x, y, 0f ), size, wall );
	}

	private void Wall( Vector3 position, Vector3 size, bool doorway, Vector3 roomCenter, List<ObstacleBounds> obstacles )
	{
		if ( !doorway )
		{
			CreateWallSegment( position, size, roomCenter, obstacles );
			return;
		}
		var horizontal = size.x > size.y;
		if ( horizontal )
		{
			// A wall spanning X must be split along X. The previous Left/Right
			// offsets moved the pieces along Y and placed one inside the room.
			CreateWallSegment( position + Vector3.Forward * 675f, new Vector3( 890, size.y, size.z ), roomCenter, obstacles );
			CreateWallSegment( position + Vector3.Backward * 675f, new Vector3( 890, size.y, size.z ), roomCenter, obstacles );
		}
		else
		{
			// A wall spanning Y is split along Y.
			CreateWallSegment( position + Vector3.Left * 675f, new Vector3( size.x, 890, size.z ), roomCenter, obstacles );
			CreateWallSegment( position + Vector3.Right * 675f, new Vector3( size.x, 890, size.z ), roomCenter, obstacles );
		}
	}

	private void CreateWallSegment( Vector3 position, Vector3 size, Vector3 roomCenter, List<ObstacleBounds> obstacles )
	{
		var wall = PrototypeFactory.Box( "Room Wall", position, size, new Color( .22f, .28f, .36f ), true, _geometry );
		RuinVisualFactory.DecorateWall( wall, size, (p, dimensions) => obstacles.Add( new ObstacleBounds( (p - roomCenter).WithZ(0), dimensions.WithZ(0) * .5f, false ) ) );
		obstacles.Add( new ObstacleBounds( (position - roomCenter).WithZ( 0f ), size.WithZ( 0f ) * .5f, true ) );
	}

	public bool TryAdvance( IEnumerable<Vector3> playerPositions )
	{
		if ( ActiveRoom >= _rooms.Count - 1 ) return false;
		var players = playerPositions.ToList();
		if ( players.Count == 0 ) return false;

		var current = _rooms[ActiveRoom].World( RoomSpacing );
		var next = _rooms[ActiveRoom + 1].World( RoomSpacing );
		var direction = (next - current).Normal.WithZ( 0f );
		var gate = _gates[ActiveRoom];

		// Entering combat and sealing its entrance are one atomic transition. Every
		// living player must be inside the destination room and far enough beyond
		// the gate that enabling its collider cannot overlap or strand anyone.
		foreach ( var playerPosition in players )
		{
			var local = playerPosition - next;
			if ( MathF.Abs( local.x ) > RoomHalfSize - 24f || MathF.Abs( local.y ) > RoomHalfSize - 24f ) return false;
			var fromGate = (playerPosition - gate.WorldPosition).WithZ( 0f );
			var depth = fromGate.x * direction.x + fromGate.y * direction.y;
			if ( depth < EntranceSealClearance ) return false;
		}

		gate.Enabled = true;
		ActiveRoom++;
		_pendingEntrance = -1;
		return true;
	}

	public void ApplyRemoteRoomState( int room, bool exitOpen )
	{
		ActiveRoom = Math.Clamp( room, 0, Math.Max( 0, _rooms.Count - 1 ) );
		for ( var i = 0; i < _gates.Count; i++ )
			_gates[i].Enabled = i != ActiveRoom || !exitOpen;
	}

	public void OpenExit()
	{
		if ( ActiveRoom >= 0 && ActiveRoom < _gates.Count ) _gates[ActiveRoom].Enabled = false;
	}

	public void CloseEntrance()
	{
		var entrance = ActiveRoom - 1;
		if ( entrance >= 0 && entrance < _gates.Count ) _pendingEntrance = entrance;
	}

	public void UpdateEntranceGate( IEnumerable<Vector3> playerPositions )
	{
		if ( _pendingEntrance < 0 || _pendingEntrance >= _gates.Count ) return;
		var gate = _gates[_pendingEntrance];
		// Never materialize a solid gate or its pillars around a player. Wait until
		// the complete party has cleared the connector before enabling collision.
		if ( playerPositions.Any( position => position.WithZ( 0f ).Distance( gate.WorldPosition.WithZ( 0f ) ) < 420f ) ) return;
		gate.Enabled = true;
		_pendingEntrance = -1;
	}

	public bool IsClearForSpawn( Vector3 worldPosition, float radius )
		=> IsClearForSpawn( worldPosition, radius, ActiveRoom );

	public bool IsClearForSpawn( Vector3 worldPosition, float radius, int roomIndex )
	{
		if ( roomIndex < 0 || roomIndex >= _rooms.Count || !_obstacles.TryGetValue( roomIndex, out var obstacles ) ) return false;
		var local = (worldPosition - _rooms[roomIndex].World( RoomSpacing )).WithZ( 0f );
		if ( MathF.Abs( local.x ) > RoomHalfSize - radius - 70f || MathF.Abs( local.y ) > RoomHalfSize - radius - 70f ) return false;
		return obstacles.All( obstacle => !obstacle.Contains( local, radius + 45f ) );
	}

	public Vector3 GetWaypoint( Vector3 from, Vector3 target, float agentRadius, float extraClearance = 0f )
	{
		if ( !_obstacles.TryGetValue( ActiveRoom, out var obstacles ) || obstacles.Count == 0 ) return target;
		var direct = Scene.Trace.Sphere( agentRadius, from + Vector3.Up * 24f, target + Vector3.Up * 24f )
			.WithoutTags( "enemy", "player", "projectile" ).Run();
		if ( !direct.Hit ) return target;

		const float cellSize = 96f;
		var gridHalf = (int)MathF.Floor( (RoomHalfSize - agentRadius - 96f) / cellSize );
		var center = CurrentCenter;
		(int x, int y) ToCell( Vector3 point )
		{
			var local = point - center;
			return (Math.Clamp( (int)MathF.Round( local.x / cellSize ), -gridHalf, gridHalf ), Math.Clamp( (int)MathF.Round( local.y / cellSize ), -gridHalf, gridHalf ));
		}
		Vector3 ToWorld( (int x, int y) cell ) => center + new Vector3( cell.x * cellSize, cell.y * cellSize, from.z - center.z );
		bool Blocked( (int x, int y) cell )
		{
			var local = new Vector3( cell.x * cellSize, cell.y * cellSize, 0f );
			return obstacles.Any( obstacle => obstacle.Contains( local, agentRadius + 48f + extraClearance ) );
		}
		var start = ToCell( from );
		var goal = ToCell( target );
		// Once the agent and its approach point occupy the same navigation cell,
		// returning that cell's centre makes close-range enemies orbit or stand
		// still—most visibly when the player is against a wall. At this distance
		// the precise target is the only useful final waypoint.
		if ( start == goal ) return target;
		if ( Blocked( goal ) )
		{
			var replacement = goal;
			var found = false;
			for ( var radius = 1; radius <= 4 && !found; radius++ )
			{
				for ( var x = -radius; x <= radius && !found; x++ )
				for ( var y = -radius; y <= radius && !found; y++ )
				{
					var candidate = (x: goal.x + x, y: goal.y + y);
					if ( Math.Abs( candidate.x ) <= gridHalf && Math.Abs( candidate.y ) <= gridHalf && !Blocked( candidate ) ) { replacement = candidate; found = true; }
				}
			}
			goal = replacement;
		}

		var queue = new Queue<(int x, int y)>();
		var parents = new Dictionary<(int x, int y), (int x, int y)>();
		var visited = new HashSet<(int x, int y)> { start };
		queue.Enqueue( start );
		// Near a wall the rounded start cell may be inside the padded obstacle.
		// Connect the actual position to nearby free cells with collision-checked
		// segments so padding cannot strand an otherwise movable agent.
		for ( var x = -3; Blocked( start ) && x <= 3; x++ )
		for ( var y = -3; y <= 3; y++ )
		{
			var next = (x: start.x + x, y: start.y + y);
			if ( Math.Abs( next.x ) > gridHalf || Math.Abs( next.y ) > gridHalf || visited.Contains( next ) || Blocked( next ) ) continue;
			var point = ToWorld( next );
			var exit = Scene.Trace.Sphere( agentRadius, from + Vector3.Up * 24f, point + Vector3.Up * 24f )
				.WithoutTags( "enemy", "player", "projectile" ).Run();
			if ( exit.Hit ) continue;
			visited.Add( next ); parents[next] = start; queue.Enqueue( next );
		}
		var directions = new[] { (1,0), (-1,0), (0,1), (0,-1) };
		while ( queue.Count > 0 )
		{
			var current = queue.Dequeue();
			if ( current == goal ) break;
			foreach ( var direction in directions )
			{
				var next = (x: current.x + direction.Item1, y: current.y + direction.Item2);
				if ( Math.Abs( next.x ) > gridHalf || Math.Abs( next.y ) > gridHalf || visited.Contains( next ) || Blocked( next ) ) continue;
				visited.Add( next ); parents[next] = current; queue.Enqueue( next );
			}
		}
		if ( !visited.Contains( goal ) )
			return extraClearance > 0f ? GetWaypoint( from, target, agentRadius ) : from;
		var path = new List<(int x, int y)> { goal };
		while ( path[^1] != start ) path.Add( parents[path[^1]] );
		path.Reverse();
		// A longer look-ahead prevents agents from aiming back into the same corner
		// on every short re-path and makes them commit to going around the wall.
		for ( var i = Math.Min( 5, path.Count - 1 ); i >= 1; i-- )
        {
            var point = ToWorld( path[i] );
            var segment = Scene.Trace.Sphere( agentRadius, from + Vector3.Up * 24f, point + Vector3.Up * 24f ).WithoutTags( "enemy", "player", "projectile" ).Run();
            if ( !segment.Hit ) return point;
        }
        return from;
	}

	private readonly record struct ObstacleCandidate( Vector3 Offset, Vector3 Size, bool IsWall );

	private readonly record struct ObstacleBounds( Vector3 Center, Vector3 HalfSize, bool IsWall )
	{
		public bool Contains( Vector3 point, float padding ) =>
			MathF.Abs( point.x - Center.x ) < HalfSize.x + padding && MathF.Abs( point.y - Center.y ) < HalfSize.y + padding;
		public bool Overlaps( ObstacleBounds other, float padding ) =>
			MathF.Abs( Center.x - other.Center.x ) < HalfSize.x + other.HalfSize.x + padding &&
			MathF.Abs( Center.y - other.Center.y ) < HalfSize.y + other.HalfSize.y + padding;
	}
}

public readonly record struct ObstacleMapItem( Vector3 WorldCenter, Vector3 Size, bool IsWall );