Grid class for A* pathfinding. Manages grid settings, cell storage and queries, cell generation and tagging, line-of-sight and walkability checks, jump/drop connection assignment, and threaded generation tasks against a Scene.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Sandbox;
namespace GridAStar;
public partial class Grid : IValid
{
public static Grid Main
{
get => Grids.GetValueOrDefault( "main" );
set
{
if ( Grids.ContainsKey( "main" ) )
Grids["main"] = value;
else
Grids.Add( "main", value );
}
}
public static Dictionary<string, Grid> Grids { get; set; } = new();
/// <summary>The scene this grid traces against. Set on creation (Scene-System port).</summary>
public Scene Scene { get; set; }
// --- Generation diagnostics ---
public static int DebugCastsHit;
public static int DebugAngleRejected;
public static int DebugOutOfBounds;
public GridBuilder Settings { get; internal set; }
public string Identifier => Settings.Identifier;
public Dictionary<IntVector2, List<Cell>> CellStacks { get; internal set; } = new();
public IEnumerable<Cell> AllCells => CellStacks.Values.SelectMany( list => list );
public Vector3 Position => Settings.Position;
public BBox Bounds => Settings.Bounds;
public BBox RotatedBounds => Bounds.GetRotatedBounds( Rotation );
public BBox WorldBounds => RotatedBounds.Translate( Position );
public Transform Transform => new Transform( WorldBounds.Center, AxisRotation );
public Rotation Rotation => Settings.Rotation;
public bool AxisAligned => Settings.AxisAligned;
public float StandableAngle => Settings.StandableAngle;
public float StepSize => Settings.StepSize;
public float CellSize => Settings.CellSize;
public float HeightClearance => Settings.HeightClearance;
public float WidthClearance => Settings.WidthClearance;
public bool GridPerfect => Settings.GridPerfect;
public bool StaticOnly => Settings.StaticOnly;
public float MaxDropHeight => Settings.MaxDropHeight;
public List<JumpDefinition> JumpDefinitions => Settings.JumpDefinitions;
public int MinNeighbourCount => Settings.MinNeighbourCount;
public bool IgnoreConnectionsForJumps => Settings.IgnoreConnectionsForJumps;
public bool IgnoreLOSForJumps => Settings.IgnoreLOSForJumps;
public bool CylinderShaped => Settings.CylinderShaped;
public float Tolerance => GridPerfect ? 0.001f : 0f;
public Rotation AxisRotation => AxisAligned ? new Rotation() : Rotation;
public int MinimumColumn => WorldBounds.Mins.ToIntVector2( CellSize ).y;
public int MaximumColumn => WorldBounds.Maxs.ToIntVector2( CellSize ).y;
public int Columns => MaximumColumn - MinimumColumn;
public int MinimumRow => WorldBounds.Mins.ToIntVector2( CellSize ).x;
public int MaximumRow => WorldBounds.Maxs.ToIntVector2( CellSize ).x;
public int Rows => MaximumRow - MinimumRow;
bool IValid.IsValid { get; }
public Grid()
{
Settings = new GridBuilder();
}
public Grid( GridBuilder settings )
{
Settings = settings;
}
public void Print( string message ) => Print( Identifier, message );
public static void Print( string identifier, string message ) => Log.Info( $"Grid '{identifier}': {message}" );
public BBox ToWorld( BBox bounds ) => bounds.GetRotatedBounds( AxisRotation ).Translate( WorldBounds.Center );
public BBox ToLocal( BBox bounds ) => bounds.GetRotatedBounds( AxisRotation.Inverse ).Translate( -WorldBounds.Center );
public IntVector2 PositionToCoordinates( Vector3 position ) => (position - WorldBounds.Mins - CellSize / 2).ToIntVector2( CellSize );
/// <summary>Find the nearest cell from a position even if outside the grid (expensive).</summary>
public Cell GetNearestCell( Vector3 position, bool onlyBelow = true, bool unoccupiedOnly = false )
{
var validCells = AllCells;
if ( unoccupiedOnly )
validCells = validCells.Where( x => !x.Occupied );
if ( onlyBelow )
validCells = validCells.Where( x => x.Vertices.Min() - Math.Max( HeightClearance, StepSize ) <= position.z );
return validCells.OrderBy( x => x.Position.DistanceSquared( position ) )
.FirstOrDefault();
}
public Cell GetCellInArea( Vector3 position, float width, bool onlyBelow = true, bool withinStepRange = true )
{
var cellsToCheck = (int)Math.Ceiling( width / CellSize ) * 2;
for ( int y = 0; y <= cellsToCheck; y++ )
{
var spiralY = MathAStar.SpiralPattern( y );
for ( int x = 0; x <= cellsToCheck; x++ )
{
var spiralX = MathAStar.SpiralPattern( x );
var cellFound = GetCell( position + AxisRotation.Forward * spiralX * CellSize + AxisRotation.Right * spiralY * CellSize + Vector3.Up * StepSize, onlyBelow );
if ( cellFound == null ) continue;
if ( withinStepRange )
if ( position.z - cellFound.Position.z <= Math.Max( HeightClearance, StepSize ) ) return cellFound; else continue;
return cellFound;
}
}
return null;
}
public Cell GetCell( Vector3 position, bool onlyBelow = true ) => GetCell( PositionToCoordinates( position ), onlyBelow ? position.z : WorldBounds.Maxs.z );
public Cell GetCell( IntVector2 coordinates, float height )
{
var cellsAtCoordinates = CellStacks.GetValueOrDefault( coordinates );
if ( cellsAtCoordinates == null ) return null;
// Return the cell CLOSEST to the query height among the candidates, not the first match. A column on
// a spiral staircase / multi-floor area stacks several cells at the same XY; the original first-match
// returned an arbitrary one (often the bottom of the spiral), so an NPC partway up got a path from the
// wrong height and couldn't follow it. Candidate window (<= height + clearance) is unchanged.
Cell best = null;
var bestDist = float.MaxValue;
foreach ( var cell in cellsAtCoordinates )
{
if ( cell.Vertices.Min() - Math.Max( HeightClearance, StepSize ) >= height )
continue;
var dist = Math.Abs( cell.Position.z - height );
if ( dist < bestDist )
{
bestDist = dist;
best = cell;
}
}
return best;
}
public void AddCell( Cell cell )
{
if ( cell == null ) return;
var coordinates = cell.GridPosition;
if ( !CellStacks.ContainsKey( coordinates ) )
CellStacks.Add( coordinates, new List<Cell>() { cell } );
else
if ( !CellStacks[coordinates].Any( x => Math.Abs( x.Position.z - cell.Position.z ) < Math.Max( HeightClearance, StepSize ) ) )
CellStacks[coordinates].Add( cell );
}
public Cell GetCellInDirection( Cell startingCell, Vector3 direction, int numOfCellsInDirection = 1 ) => GetCell( startingCell.Position + direction * CellSize * numOfCellsInDirection );
public Cell GetNeighbourInDirection( Cell cell, Vector3 direction )
{
var horizontalDirection = direction.WithZ( 0 ).Normal;
var localCoordinates = horizontalDirection.ToIntVector2();
var coordinatesToCheck = cell.GridPosition + localCoordinates;
var cellsAtCoordinates = CellStacks.GetValueOrDefault( coordinatesToCheck );
if ( cellsAtCoordinates == null ) return null;
foreach ( var cellAtCoordinate in cellsAtCoordinates )
if ( cell.IsNeighbour( cellAtCoordinate ) && cell != cellAtCoordinate )
return cellAtCoordinate;
return null;
}
/// <summary>Returns if there's a valid, unoccupied, and direct line of sight from a cell to another</summary>
public bool LineOfSight( Cell startingCell, Cell endingCell, Component pathCreator = null, bool debugShow = false )
{
var startingPosition = startingCell.Position;
var endingPosition = endingCell.Position;
var distanceInSteps = (int)Math.Ceiling( startingPosition.Distance( endingPosition ) / CellSize );
if ( pathCreator == null && startingCell.Occupied ) return false;
if ( pathCreator != null && startingCell.Occupied && startingCell.OccupyingEntity != pathCreator ) return false;
if ( pathCreator == null && endingCell.Occupied ) return false;
if ( pathCreator != null && endingCell.Occupied && endingCell.OccupyingEntity != pathCreator ) return false;
Cell lastCell = startingCell;
for ( int i = 0; i <= distanceInSteps; i++ )
{
var direction = (endingPosition - lastCell.Position).Normal;
var cellToCheck = GetNeighbourInDirection( lastCell, direction );
if ( cellToCheck == null ) return false;
if ( cellToCheck == endingCell ) return true;
if ( cellToCheck == lastCell ) continue;
if ( pathCreator == null && cellToCheck.Occupied ) return false;
if ( pathCreator != null && cellToCheck.Occupied && cellToCheck.OccupyingEntity != pathCreator ) return false;
if ( !cellToCheck.IsNeighbour( lastCell ) ) return false;
lastCell = cellToCheck;
if ( debugShow )
lastCell.Draw( 2f, false, false, false );
}
return true;
}
/// <summary>Can you roughly walk towards the cell without it being a direct line of sight</summary>
public bool IsDirectlyWalkable( Cell startingCell, Cell endingCell, float maxDistanceFromDirectPath = 150f, Component pathCreator = null, bool withConnections = true )
{
if ( startingCell == null || endingCell == null ) return false;
var currentCell = startingCell;
var directPath = new Line( startingCell.Position.WithZ( 0 ), endingCell.Position.WithZ( 0 ) );
List<Cell> cellsChecked = new();
if ( pathCreator == null && startingCell.Occupied ) return false;
if ( pathCreator != null && startingCell.Occupied && startingCell.OccupyingEntity != pathCreator ) return false;
if ( pathCreator == null && endingCell.Occupied ) return false;
if ( pathCreator != null && endingCell.Occupied && endingCell.OccupyingEntity != pathCreator ) return false;
while ( currentCell != endingCell && directPath.Distance( currentCell.Position.WithZ( 0 ) ) <= maxDistanceFromDirectPath )
{
var cellToCheck = withConnections ? currentCell.GetClosestNeighbourAndConnection( endingCell.Position ) : currentCell.GetClosestNeighbour( endingCell.Position );
if ( cellToCheck == null ) return false;
if ( cellsChecked.Contains( cellToCheck ) ) return false;
if ( pathCreator == null && cellToCheck.Occupied ) return false;
if ( pathCreator != null && cellToCheck.Occupied && cellToCheck.OccupyingEntity != pathCreator ) return false;
if ( cellToCheck == endingCell ) return true;
cellsChecked.Add( currentCell );
currentCell = cellToCheck;
}
return false;
}
public bool IsInsideBounds( Vector3 point ) => Bounds.IsRotatedPointWithinBounds( Position, point, Rotation );
public bool IsInsideCylinder( Vector3 point ) => Bounds.IsInsideSquishedRotatedCylinder( Position, point, Rotation );
public void Initialize()
{
if ( Grids.ContainsKey( Identifier ) )
{
if ( Grids[Identifier] != null )
Grids[Identifier].Delete( true );
Grids[Identifier] = this;
}
else
Grids.Add( Identifier, this );
}
public void Delete( bool deleteSave = false )
{
if ( Grids.ContainsKey( Identifier ) )
{
Grids[Identifier] = null;
Grids.Remove( Identifier );
}
}
public List<Cell> GetCellsInBBox( BBox bbox )
{
var cells = new List<Cell>();
foreach ( var cell in AllCells )
if ( bbox.Contains( cell.Position ) )
cells.Add( cell );
return cells;
}
public override int GetHashCode() => Settings.GetHashCode();
/// <summary>Gives the edge tag to all cells with less than 8 neighbours</summary>
public async Task AssignEdgeCells( int maxNeighourCount = 8, int threadsToUse = 1, bool clearTags = false, string tagToExclude = "", string tagToAssign = "edge" ) => await assignEdgeCellsInternal( AllCells.ToList(), maxNeighourCount, threadsToUse, clearTags, tagToExclude, tagToAssign );
public async Task AssignEdgeCells( BBox bounds, int maxNeighourCount = 8, int threadsToUse = 1, bool clearTags = false, string tagToExclude = "", string tagToAssign = "edge" ) => await assignEdgeCellsInternal( GetCellsInBBox( bounds ), maxNeighourCount, threadsToUse, clearTags, tagToExclude, tagToAssign );
internal async Task assignEdgeCellsInternal( List<Cell> cells, int maxNeighourCount = 8, int threadsToUse = 1, bool clearTags = false, string tagToExclude = "", string tagToAssign = "edge" )
{
var cellsCount = cells.Count();
threadsToUse = Math.Max( 1, threadsToUse );
var cellsEachThread = (int)(cellsCount / threadsToUse);
var lastThreadCount = cellsCount - (cellsEachThread * (threadsToUse - 1));
List<Task> tasks = new();
for ( int i = 0; i < threadsToUse; i++ )
{
var curentThread = i;
tasks.Add( GameTask.RunInThreadAsync( () =>
{
var cellsRange = curentThread == threadsToUse - 1 ? cellsEachThread : lastThreadCount;
var cellsToCheck = cells.Skip( cellsEachThread * curentThread ).Take( cellsRange );
foreach ( var cell in cellsToCheck )
{
if ( clearTags )
cell.Tags.Remove( tagToAssign );
var neighbours = cell.GetNeighbours();
if ( tagToExclude != "" )
neighbours = neighbours.Where( x => !x.Tags.Has( tagToExclude ) );
if ( neighbours.Count() < maxNeighourCount )
cell.Tags.Add( tagToAssign );
}
} ) );
}
await GameTask.WhenAll( tasks );
}
/// <summary>Adds the droppable connection to cells you can drop from</summary>
public async Task AssignDroppableCells( int threadsToUse = 1 ) => await internalAssignDroppableCells( CellsWithTag( "edge" ).ToList(), threadsToUse );
public async Task AssignDroppableCells( BBox bounds, int threadsToUse = 1 ) => await internalAssignDroppableCells( CellsWithTag( bounds, "edge" ).ToList(), threadsToUse );
internal async Task internalAssignDroppableCells( List<Cell> cells, int threadsToUse = 1 )
{
var allCells = cells;
var cellsCount = allCells.Count();
threadsToUse = Math.Max( 1, threadsToUse );
var cellsEachThread = (int)(cellsCount / threadsToUse);
var lastThreadCount = cellsCount - (cellsEachThread * (threadsToUse - 1));
List<Task> tasks = new();
for ( int i = 0; i < threadsToUse; i++ )
{
var curentThread = i;
tasks.Add( GameTask.RunInThreadAsync( () =>
{
var cellsRange = curentThread == threadsToUse - 1 ? cellsEachThread : lastThreadCount;
var cellsToCheck = allCells.Skip( cellsEachThread * curentThread ).Take( cellsRange );
foreach ( var cell in cellsToCheck )
{
var droppableCell = cell.GetFirstValidDroppable( maxHeightDistance: MaxDropHeight );
if ( droppableCell != null )
cell.AddConnection( droppableCell, "drop" );
}
} ) );
}
await GameTask.WhenAll( tasks );
}
public IEnumerable<Cell> JumpableCandidates()
{
var droppedCells = CellsWithConnection( "drop" ).SelectMany( cell => cell.GetConnections( "drop" ).Select( connection => connection.Current ) );
return CellsWithTag( "edge" ).Concat( droppedCells );
}
public async Task AssignJumpableCells( JumpDefinition definition, int threadsToUse = 16 ) => await internalAssignJumpableCells( JumpableCandidates().ToList(), definition, threadsToUse );
internal async Task internalAssignJumpableCells( List<Cell> cells, JumpDefinition definition, int threadsToUse = 16 )
{
var allCells = cells;
var cellsCount = allCells.Count();
threadsToUse = Math.Max( 1, threadsToUse );
var cellsEachThread = (int)(cellsCount / threadsToUse);
var lastThreadCount = cellsCount - (cellsEachThread * (threadsToUse - 1));
List<Task> tasks = new();
for ( int i = 0; i < threadsToUse; i++ )
{
var curentThread = i;
tasks.Add( GameTask.RunInThreadAsync( () =>
{
var totalFraction = 1f;
var cellsRange = curentThread == threadsToUse - 1 ? cellsEachThread : lastThreadCount;
var cellsToCheck = allCells.Skip( cellsEachThread * curentThread ).Take( cellsRange );
foreach ( var cell in cellsToCheck )
{
if ( totalFraction >= 1f )
{
List<Cell> connectedCells = new();
List<AStarNode> jumpConnections = new();
foreach ( var jumpableCell in cell.GetValidJumpables( definition, MaxDropHeight, IgnoreConnectionsForJumps, IgnoreLOSForJumps ) )
if ( jumpableCell != null )
{
jumpConnections.Add( cell.AddConnection( jumpableCell, definition.Name ) );
connectedCells.Add( jumpableCell );
}
foreach ( var jumpableConnection in connectedCells )
{
var direction = (cell.Position - jumpableConnection.Position).WithZ( 0 ).Normal;
var jumpbackCell = jumpableConnection.GetValidJumpable( definition, direction, MaxDropHeight, IgnoreConnectionsForJumps, IgnoreLOSForJumps );
if ( jumpbackCell != null )
if ( !IsDirectlyWalkable( jumpbackCell, cell ) )
{
var duplicate = false;
foreach ( var connection in jumpConnections )
if ( connection.Parent.Current == jumpbackCell && connection.MovementTag == definition.Name )
duplicate = true;
if ( !duplicate )
jumpConnections.Add( jumpableConnection.AddConnection( jumpbackCell, definition.Name ) );
}
}
foreach ( var connection in jumpConnections )
if ( LineOfSight( connection.Parent.Current, connection.Current ) )
connection.Parent.Current.RemoveConnection( connection );
totalFraction = 0f;
}
totalFraction += definition.GenerateFraction;
}
} ) );
}
await GameTask.WhenAll( tasks );
}
public Vector3 TraceParabola( Vector3 startingPosition, Vector3 horizontalVelocity, float verticalSpeed, float gravity, float maxDropHeight, int subSteps = 2 )
{
var horizontalDirection = horizontalVelocity.WithZ( 0 ).Normal;
var horizontalSpeed = horizontalVelocity.WithZ( 0 ).Length;
var maxHeight = startingPosition.z + MathAStar.ParabolaMaxHeight( verticalSpeed, gravity );
var minHeight = maxHeight - maxDropHeight;
var currentDistance = 1;
var lastPositionChecked = startingPosition;
while ( lastPositionChecked.z >= minHeight )
{
var horizontalOffset = CellSize * currentDistance / subSteps;
var verticalOffset = MathAStar.ParabolaHeight( horizontalOffset, horizontalSpeed, verticalSpeed, gravity );
var nextPositionToCheck = startingPosition + horizontalDirection * horizontalOffset + Vector3.Up * verticalOffset;
var clearanceBBox = new BBox( new Vector3( -WidthClearance / 2f, -WidthClearance / 2f, StepSize ), new Vector3( WidthClearance / 2f, WidthClearance / 2f, HeightClearance ) );
var jumpTrace = Scene.Trace.Box( clearanceBBox, lastPositionChecked, nextPositionToCheck )
.WithGridSettings( Settings )
.Run();
if ( jumpTrace.Hit )
return jumpTrace.EndPosition;
lastPositionChecked = nextPositionToCheck;
currentDistance++;
}
return lastPositionChecked;
}
public void RemoveCells( BBox bounds, bool printInfo = false )
{
var cellsToRemove = GetCellsInBBox( bounds );
var count = cellsToRemove.Count();
foreach ( var cell in cellsToRemove )
cell.Delete();
if ( printInfo )
Print( $"Removed {count} cells" );
}
public async Task GenerateCells( BBox bounds, int threadedChunkSides = 1, bool printInfo = true )
{
List<Task<List<Cell>>> tasks = new();
var totalMins = bounds.Mins;
var totalMaxs = bounds.Maxs;
var totalSize = bounds.Size;
threadedChunkSides = Math.Max( 1, threadedChunkSides );
for ( int x = 1; x <= threadedChunkSides; x++ )
{
for ( int y = 1; y <= threadedChunkSides; y++ )
{
var xOffset = totalSize.x / threadedChunkSides * x - totalSize.x / threadedChunkSides / 2;
var yOffset = totalSize.y / threadedChunkSides * y - totalSize.y / threadedChunkSides / 2;
var offset = new Vector3( xOffset, yOffset );
var chunkSize = totalSize / threadedChunkSides;
var chunkMins = totalMins + offset - chunkSize / 2;
var chunkMaxs = totalMins + offset + chunkSize / 2;
var dividedBounds = new BBox( chunkMins.WithZ( totalMins.z ), chunkMaxs.WithZ( totalMaxs.z ) );
tasks.Add( GameTask.RunInThreadAsync( () => createCells( dividedBounds, printInfo ) ) );
}
}
await GameTask.WhenAll( tasks );
foreach ( var task in tasks )
foreach ( var cell in task.Result )
AddCell( cell );
}
/// <summary>Create cells in that local bbox (Doesn't add them)</summary>
private List<Cell> createCells( BBox bounds, bool printInfo = true )
{
var generatedCells = new List<Cell>();
var minimumGrid = bounds.Mins.ToIntVector2( CellSize );
var maximumGrid = bounds.Maxs.ToIntVector2( CellSize );
var startingColumn = minimumGrid.y - MinimumColumn;
var totalColumns = maximumGrid.y - minimumGrid.y;
var endingColumn = startingColumn + totalColumns;
var startingRow = minimumGrid.x - MinimumRow;
var totalRows = maximumGrid.x - minimumGrid.x;
var endingRow = startingRow + totalRows;
for ( int column = startingColumn; column < endingColumn; column++ )
{
for ( int row = startingRow; row < endingRow; row++ )
{
var startPosition = WorldBounds.Mins.WithZ( WorldBounds.Maxs.z ) + new Vector3( row * CellSize + CellSize / 2f, column * CellSize + CellSize / 2f, Tolerance * 2f ) * AxisRotation;
var endPosition = WorldBounds.Mins + new Vector3( row * CellSize + CellSize / 2f, column * CellSize + CellSize / 2f, -Tolerance ) * AxisRotation;
var checkBBox = new BBox( new Vector3( -CellSize / 2f + Tolerance, -CellSize / 2f + Tolerance, 0f ), new Vector3( CellSize / 2f - Tolerance, CellSize / 2f - Tolerance, 0.001f ) );
var positionTrace = Scene.Trace.Box( checkBBox, startPosition, endPosition )
.WithGridSettings( Settings );
var positionResult = positionTrace.Run();
while ( positionResult.Hit && startPosition.z >= endPosition.z )
{
DebugCastsHit++;
if ( IsInsideBounds( positionResult.HitPosition ) )
{
if ( !CylinderShaped || IsInsideCylinder( positionResult.HitPosition ) )
{
var angle = Vector3.GetAngle( Vector3.Up, positionResult.Normal );
if ( angle <= StandableAngle )
{
var newCell = Cell.TryCreate( this, positionResult.HitPosition );
if ( newCell != null )
generatedCells.Add( newCell );
}
else
{
DebugAngleRejected++;
}
}
}
else
{
DebugOutOfBounds++;
}
startPosition = positionResult.HitPosition + Vector3.Down * HeightClearance;
// Scene-System port of Sandbox.Trace.TestPoint: a zero-length sphere trace reports
// StartedSolid when the point is inside geometry. Step down until we're clear.
while ( Scene.Trace.Sphere( CellSize / 2f - Tolerance, startPosition, startPosition ).Run().StartedSolid )
startPosition += Vector3.Down * HeightClearance;
positionTrace = Scene.Trace.Box( checkBBox, startPosition, endPosition )
.WithGridSettings( Settings );
positionResult = positionTrace.Run();
}
}
}
return generatedCells;
}
public IEnumerable<Cell> CellsWithTag( string tag ) => AllCells.Where( cell => cell.Tags.Has( tag ) );
public IEnumerable<Cell> CellsWithTags( params string[] tags ) => AllCells.Where( cell => cell.Tags.Has( tags ) );
public IEnumerable<Cell> CellsWithTags( List<string> tags ) => AllCells.Where( cell => cell.Tags.Has( tags ) );
public IEnumerable<Cell> CellsWithTag( BBox bounds, string tag ) => GetCellsInBBox( bounds ).Where( cell => cell.Tags.Has( tag ) );
public IEnumerable<Cell> CellsWithTags( BBox bounds, params string[] tags ) => GetCellsInBBox( bounds ).Where( cell => cell.Tags.Has( tags ) );
public IEnumerable<Cell> CellsWithTags( BBox bounds, List<string> tags ) => GetCellsInBBox( bounds ).Where( cell => cell.Tags.Has( tags ) );
public IEnumerable<Cell> CellsWithConnection( string movementTag ) => AllCells.Where( cell => cell.GetConnections( movementTag ).Count() > 0 );
public IEnumerable<Cell> CellsWithConnection( BBox bounds, string movementTag ) => GetCellsInBBox( bounds ).Where( cell => cell.GetConnections( movementTag ).Count() > 0 );
public void CheckOccupancy( string tag )
{
foreach ( var cell in AllCells )
cell.Occupied = cell.TestForOccupancy( tag );
}
}