I'm implementing a BFS traversal in a grid-like structure. Because I wanted to experiment with ES6 Generators for a long time, I thought I'd implement the search using that abstraction.
I am sure there are obvious things I could improve in the code, because it looks fishy to me, especially after some "special constraints" that I needed to add.
Moreover, the code takes "forever" to run, and I was looking into any performance tips.
Background and constraints
- What I am looking for is not really pathfinding, actually. I need to return an area for all the possible grid tiles an entity could reach.
- Each entity has a certain
movement range
score. - Each entity has a certain
action
score. For everyaction
point, the entity can move up to its entiremovement range
. - The boundaries between each range multiplier need to be clearly visible (so they must be returned in different groups, and can't just be added together).
- Diagonal movement across the grid counts as
1.5
, rather then the more classic Manhattan-distance2
.
Here are two images of what the result should be like:
As you can see in the first one, the green entity has 6 movement
range and 1 action
. In the second one, it is the same entity with 4 actions
, the different shades show the different areas.
Implementation
Adjacent tiles
First of all, I've got two simple helper functions to get tiles which are adjacent or diagonal from a given tile. Perhaps I could implement these in some other way too, so feedback is accepted.
getTilesAdjacentTo(tile) {
const cost = tile.cost || 0;
return [
{ x: tile.x, y: tile.y + 1, cost: cost + 1 },
{ x: tile.x, y: tile.y - 1, cost: cost + 1 },
{ x: tile.x + 1, y: tile.y, cost: cost + 1 },
{ x: tile.x - 1, y: tile.y, cost: cost + 1 }
].filter(tile => {
return (tile.x >= 0 && tile.x < this.size.x)
&& (tile.y >= 0 && tile.y < this.size.y)
});
}
getTilesDiagonalTo(tile) {
const cost = tile.cost || 0;
return [
{ x: tile.x - 1, y: tile.y - 1, cost: cost + 1.5 },
{ x: tile.x - 1, y: tile.y + 1, cost: cost + 1.5 },
{ x: tile.x + 1, y: tile.y + 1, cost: cost + 1.5 },
{ x: tile.x + 1, y: tile.y - 1, cost: cost + 1.5 }
].filter(tile => {
return (tile.x >= 0 && tile.x < this.size.x)
&& (tile.y >= 0 && tile.y < this.size.y)
});
}
It filters out tiles that are outside of the grid.
Search function
searchAroundTile: function* (tile, type = 'pathableOnly') {
let costStep = 0,
diagonalTiles = [],
frontier = [tile],
visitedTiles = [],
currentTile;
while(true) {
// Get the first tile out of the frontier
currentTile = frontier.shift();
if(!currentTile)
break;
// I want to yield every time a circle of range 1 has been completed
if(currentTile.cost <= costStep) {
if(
// Checking that the tile has not been visited yet.
// Is there a better way not to add tiles that have been
// visited to the frontier?
!visitedTiles.some(
tile => tile.x === currentTile.x && tile.y === currentTile.y
)
&& (
// Checks if the tile is pathable.
// The first check is required because the tile with the
// entity on it is technically not pathable.
currentTile === tile
|| type === 'all'
|| this.isPathable(currentTile)
)
) {
frontier = [ ...frontier, ...this.getTilesAdjacentTo(currentTile) ];
// I save diagonal tiles separately and concat them later so
// that I can be sure that the frontier is kept in order of
// cost, basically.
diagonalTiles = [ ...diagonalTiles, ...this.getTilesDiagonalTo(currentTile) ];
visitedTiles.push(currentTile);
}
}
else {
frontier.unshift(currentTile);
frontier = [ ...frontier, ...diagonalTiles ];
costStep += 1;
yield visitedTiles;
}
if(!frontier.length)
break;
}
yield visitedTiles;
}
Calculate range function
calculateRange(from, range, min = 0, rangeType = 'all', repeat = 1) {
let area = [],
currentCost = 0,
i = 1,
search = this.searchAroundTile(
{ ...from, cost: 0 },
rangeType
),
visitedTiles;
// This is in order to divide each repeat, but not to have to start
// a separate search from scratch. That's where I think the generator
// pattern helps also.
while(repeat) {
visitedTiles = [];
// Just iterate the generator until the cost of the last tile is in range.
while(currentCost < range * i) {
visitedTiles = search.next().value;
currentCost = visitedTiles[visitedTiles.length - 1].cost;
}
area.push(visitedTiles.filter(
// `min` is an optional argument in case one wants minimum ranges.
// The second filter makes sure it removes from the current area
// tiles that are not within that range. Sometimes I would end up
// with a few tiles belonging to the previous area. This is
// probably a fault in the search algorithm which I didn't find.
tile => tile.cost >= min && tile.cost > range * (i - 1)
));
i++;
repeat--;
}
// `area` is an array of arrays, so it allows me to clearly identify
// each area separately as seen in the picture above.
return area;
}
For what is worth, I have tried to quickly hack a non generator version of this code, and performance is the same. So the generator iteration doesn't really impact on the performance issue as far as I understand.