diff --git a/core/src/io/anuke/mindustry/ai/HueristicImpl.java b/core/src/io/anuke/mindustry/ai/HueristicImpl.java new file mode 100644 index 0000000000..42f3c0a7f1 --- /dev/null +++ b/core/src/io/anuke/mindustry/ai/HueristicImpl.java @@ -0,0 +1,35 @@ +package io.anuke.mindustry.ai; + +import com.badlogic.gdx.ai.pfa.Heuristic; + +import io.anuke.mindustry.Vars; +import io.anuke.mindustry.world.Tile; + +public class HueristicImpl implements Heuristic{ + /**How many times more it costs to go through a destructible block than an empty block.*/ + static final float solidMultiplier = 10f; + /**How many times more it costs to go through a tile that touches a solid block.*/ + static final float occludedMultiplier = 5f; + + @Override + public float estimate(Tile node, Tile other){ + return estimateStatic(node, other); + } + + /**Estimate the cost of walking between two tiles.*/ + public static float estimateStatic(Tile node, Tile other){ + //Get Manhattan distance cost + float cost = Math.abs(node.worldx() - other.worldx()) + Math.abs(node.worldy() - other.worldy()); + + //If either one of the tiles is a breakable solid block (that is, it's player-made), + //increase the cost by the tilesize times the multiplayer + if(node.breakable() && node.block().solid) cost += Vars.tilesize* solidMultiplier; + if(other.breakable() && other.block().solid) cost += Vars.tilesize* solidMultiplier; + + //if this block has solid blocks near it, increase the cost, as we don't want enemies hugging walls + if(node.occluded) cost += Vars.tilesize*occludedMultiplier; + + return cost; + } + +} diff --git a/core/src/io/anuke/mindustry/ai/MHueristic.java b/core/src/io/anuke/mindustry/ai/MHueristic.java deleted file mode 100644 index fed6e45b61..0000000000 --- a/core/src/io/anuke/mindustry/ai/MHueristic.java +++ /dev/null @@ -1,34 +0,0 @@ -package io.anuke.mindustry.ai; - -import com.badlogic.gdx.ai.pfa.Heuristic; - -import io.anuke.mindustry.Vars; -import io.anuke.mindustry.world.Tile; - -public class MHueristic implements Heuristic{ - //so this means that the cost of going through solids is 10x going through non solids - static float multiplier = 10f; - - @Override - public float estimate(Tile node, Tile other){ - return estimateStatic(node, other); - } - - public static float estimateStatic(Tile node, Tile other){ - float cost = Math.abs(node.worldx() - other.worldx()) + Math.abs(node.worldy() - other.worldy()); - - //TODO balance multiplier - if(node.breakable() && node.block().solid) cost += Vars.tilesize*multiplier; - if(other.breakable() && other.block().solid) cost += Vars.tilesize*multiplier; - for(int dx = -1; dx <= 1; dx ++){ - for(int dy = -1; dy <= 1; dy ++){ - Tile tile = Vars.world.tile(node.x + dx, node.y + dy); - if(tile != null && tile.solid()){ - cost += Vars.tilesize*5; - } - } - } - return cost; - } - -} diff --git a/core/src/io/anuke/mindustry/ai/OptimizedGraph.java b/core/src/io/anuke/mindustry/ai/OptimizedGraph.java new file mode 100644 index 0000000000..9f4a7bf965 --- /dev/null +++ b/core/src/io/anuke/mindustry/ai/OptimizedGraph.java @@ -0,0 +1,9 @@ +package io.anuke.mindustry.ai; + +import com.badlogic.gdx.ai.pfa.indexed.IndexedGraph; + +/**An interface for an indexed graph that doesn't use allocations for connections.*/ +public interface OptimizedGraph extends IndexedGraph { + /**This is used in the same way as getConnections(), but does not use Connection objects.*/ + public N[] connectionsOf(N node); +} diff --git a/core/src/io/anuke/mindustry/ai/OptimizedPathFinder.java b/core/src/io/anuke/mindustry/ai/OptimizedPathFinder.java new file mode 100644 index 0000000000..006d63a546 --- /dev/null +++ b/core/src/io/anuke/mindustry/ai/OptimizedPathFinder.java @@ -0,0 +1,295 @@ +package io.anuke.mindustry.ai; + +import com.badlogic.gdx.ai.pfa.*; +import com.badlogic.gdx.utils.BinaryHeap; +import com.badlogic.gdx.utils.TimeUtils; + +/**An IndexedAStarPathfinder that uses an OptimizedGraph, and therefore has less allocations.*/ +public class OptimizedPathFinder implements PathFinder { + OptimizedGraph graph; + NodeRecord[] nodeRecords; + BinaryHeap> openList; + NodeRecord current; + + /** + * The unique ID for each search run. Used to mark nodes. + */ + private int searchId; + + private static final byte UNVISITED = 0; + private static final byte OPEN = 1; + private static final byte CLOSED = 2; + + @SuppressWarnings("unchecked") + public OptimizedPathFinder(OptimizedGraph graph) { + this.graph = graph; + this.nodeRecords = (NodeRecord[]) new NodeRecord[graph.getNodeCount()]; + this.openList = new BinaryHeap<>(); + } + + @Override + public boolean searchConnectionPath(N startNode, N endNode, Heuristic heuristic, GraphPath> outPath) { + + // Perform AStar + boolean found = search(startNode, endNode, heuristic); + + if (found) { + // Create a path made of connections + generateConnectionPath(startNode, outPath); + } + + return found; + } + + @Override + public boolean searchNodePath(N startNode, N endNode, Heuristic heuristic, GraphPath outPath) { + + // Perform AStar + boolean found = search(startNode, endNode, heuristic); + + if (found) { + // Create a path made of nodes + generateNodePath(startNode, outPath); + } + + return found; + } + + protected boolean search(N startNode, N endNode, Heuristic heuristic) { + + initSearch(startNode, endNode, heuristic); + + // Iterate through processing each node + do { + // Retrieve the node with smallest estimated total cost from the open list + current = openList.pop(); + current.category = CLOSED; + + // Terminate if we reached the goal node + if (current.node == endNode) return true; + + visitChildren(endNode, heuristic); + + } while (openList.size > 0); + + // We've run out of nodes without finding the goal, so there's no solution + return false; + } + + @Override + public boolean search(PathFinderRequest request, long timeToRun) { + + long lastTime = TimeUtils.nanoTime(); + + // We have to initialize the search if the status has just changed + if (request.statusChanged) { + initSearch(request.startNode, request.endNode, request.heuristic); + request.statusChanged = false; + } + + // Iterate through processing each node + do { + + // Check the available time + long currentTime = TimeUtils.nanoTime(); + timeToRun -= currentTime - lastTime; + if (timeToRun <= PathFinderQueue.TIME_TOLERANCE) return false; + + // Retrieve the node with smallest estimated total cost from the open list + current = openList.pop(); + current.category = CLOSED; + + // Terminate if we reached the goal node; we've found a path. + if (current.node == request.endNode) { + request.pathFound = true; + + generateNodePath(request.startNode, request.resultPath); + + return true; + } + + // Visit current node's children + visitChildren(request.endNode, request.heuristic); + + // Store the current time + lastTime = currentTime; + + } while (openList.size > 0); + + // The open list is empty and we've not found a path. + request.pathFound = false; + return true; + } + + protected void initSearch(N startNode, N endNode, Heuristic heuristic) { + + // Increment the search id + if (++searchId < 0) searchId = 1; + + // Initialize the open list + openList.clear(); + + // Initialize the record for the start node and add it to the open list + NodeRecord startRecord = getNodeRecord(startNode); + startRecord.node = startNode; + //startRecord.connection = null; + startRecord.costSoFar = 0; + addToOpenList(startRecord, heuristic.estimate(startNode, endNode)); + + current = null; + } + + protected void visitChildren(N endNode, Heuristic heuristic) { + // Get current node's outgoing connections + //Array> connections = graph.getConnections(current.node); + N[] conn = graph.connectionsOf(current.node); + + // Loop through each connection in turn + for (int i = 0; i < conn.length; i++) { + + //Connection connection = connections.get(i) + + // Get the cost estimate for the node + N node = conn[i]; + + if(node == null) continue; + + float addCost = heuristic.estimate(current.node, node); + + float nodeCost = current.costSoFar + addCost; + + float nodeHeuristic; + NodeRecord nodeRecord = getNodeRecord(node); + if (nodeRecord.category == CLOSED) { // The node is closed + + // If we didn't find a shorter route, skip + if (nodeRecord.costSoFar <= nodeCost) continue; + + // We can use the node's old cost values to calculate its heuristic + // without calling the possibly expensive heuristic function + nodeHeuristic = nodeRecord.getEstimatedTotalCost() - nodeRecord.costSoFar; + } else if (nodeRecord.category == OPEN) { // The node is open + + // If our route is no better, then skip + if (nodeRecord.costSoFar <= nodeCost) continue; + + // Remove it from the open list (it will be re-added with the new cost) + openList.remove(nodeRecord); + + // We can use the node's old cost values to calculate its heuristic + // without calling the possibly expensive heuristic function + nodeHeuristic = nodeRecord.getEstimatedTotalCost() - nodeRecord.costSoFar; + } else { // the node is unvisited + + // We'll need to calculate the heuristic value using the function, + // since we don't have a node record with a previously calculated value + nodeHeuristic = heuristic.estimate(node, endNode); + } + + // Update node record's cost and connection + nodeRecord.costSoFar = nodeCost; + nodeRecord.from = current.node; //TODO ??? + + // Add it to the open list with the estimated total cost + addToOpenList(nodeRecord, nodeCost + nodeHeuristic); + } + + } + + protected void generateConnectionPath(N startNode, GraphPath> outPath) { + //do ABSOLUTELY NOTHING + /* + // Work back along the path, accumulating connections + // outPath.clear(); + while (current.node != startNode) { + outPath.add(current.connection); + current = nodeRecords[graph.getIndex(current.connection.getFromNode())]; + } + + // Reverse the path + outPath.reverse();*/ + } + + protected void generateNodePath(N startNode, GraphPath outPath) { + + // Work back along the path, accumulating nodes + // outPath.clear(); + while (current.from != null) { + outPath.add(current.node); + current = nodeRecords[graph.getIndex(current.from)]; + } + outPath.add(startNode); + + // Reverse the path + outPath.reverse(); + } + + protected void addToOpenList(NodeRecord nodeRecord, float estimatedTotalCost) { + openList.add(nodeRecord, estimatedTotalCost); + nodeRecord.category = OPEN; + } + + protected NodeRecord getNodeRecord(N node) { + int index = graph.getIndex(node); + NodeRecord nr = nodeRecords[index]; + if (nr != null) { + if (nr.searchId != searchId) { + nr.category = UNVISITED; + nr.searchId = searchId; + } + return nr; + } + nr = nodeRecords[index] = new NodeRecord<>(); + nr.node = node; + nr.searchId = searchId; + return nr; + } + + /** + * This nested class is used to keep track of the information we need for each node during the search. + * + * @param Type of node + * @author davebaol + */ + static class NodeRecord extends BinaryHeap.Node { + /** + * The reference to the node. + */ + N node; + N from; + + /** + * The incoming connection to the node + */ + //Connection connection; + + /** + * The actual cost from the start node. + */ + float costSoFar; + + /** + * The node category: {@link #UNVISITED}, {@link #OPEN} or {@link #CLOSED}. + */ + byte category; + + /** + * ID of the current search. + */ + int searchId; + + /** + * Creates a {@code NodeRecord}. + */ + public NodeRecord() { + super(0); + } + + /** + * Returns the estimated total cost. + */ + public float getEstimatedTotalCost() { + return getValue(); + } + } +} diff --git a/core/src/io/anuke/mindustry/ai/Pathfind.java b/core/src/io/anuke/mindustry/ai/Pathfind.java index 16dea5b779..9d46ee9c72 100644 --- a/core/src/io/anuke/mindustry/ai/Pathfind.java +++ b/core/src/io/anuke/mindustry/ai/Pathfind.java @@ -2,7 +2,6 @@ package io.anuke.mindustry.ai; import com.badlogic.gdx.ai.pfa.PathFinderRequest; import com.badlogic.gdx.ai.pfa.PathSmoother; -import com.badlogic.gdx.ai.pfa.indexed.IndexedAStarPathFinder; import com.badlogic.gdx.math.MathUtils; import com.badlogic.gdx.math.Vector2; import io.anuke.mindustry.Vars; @@ -17,14 +16,24 @@ import io.anuke.ucore.util.Mathf; import io.anuke.ucore.util.Tmp; public class Pathfind{ - private static final long ms = 1000000 * 500; - - MHueristic heuristic = new MHueristic(); - PassTileGraph graph = new PassTileGraph(); + /**Maximum time taken per frame on pathfinding for a single path.*/ + private static final long maxTime = 1000000 * 5; + + /**Heuristic for determining cost between two tiles*/ + HueristicImpl heuristic = new HueristicImpl(); + /**Tile graph, for determining conenctions between two tiles*/ + TileGraph graph = new TileGraph(); + /**Smoother that removes extra nodes from a path.*/ PathSmoother smoother = new PathSmoother(new Raycaster()); + /**temporary vector2 for calculations*/ Vector2 vector = new Vector2(); - + + /**Finds the position on the path an enemy should move to. + * If the path is not yet calculated, this returns the enemy's position (i. e. "don't move") + * @param enemy The enemy to find a path for + * @return The position the enemy should move to.*/ public Vector2 find(Enemy enemy){ + //TODO fix -1/-2 node usage if(enemy.node == -1 || enemy.node == -2){ findNode(enemy); } @@ -38,9 +47,10 @@ public class Pathfind{ } Tile[] path = Vars.control.getSpawnPoints().get(enemy.lane).pathTiles; - + + //if an enemy is idle for a while, it's probably stuck if(enemy.idletime > EnemyType.maxIdle){ - //TODO reverse + Tile target = path[enemy.node]; if(Vars.world.raycastWorld(enemy.x, enemy.y, target.worldx(), target.worldy()) != null) { if (enemy.node > 1) @@ -49,7 +59,6 @@ public class Pathfind{ } //else, must be blocked by a playermade block, do nothing - } //-1 is only possible here if both pathfindings failed, which should NOT happen @@ -58,7 +67,8 @@ public class Pathfind{ if(enemy.node <= -1){ return vector.set(enemy.x, enemy.y); } - + + //TODO documentation on what this does Tile prev = path[enemy.node - 1]; Tile target = path[enemy.node]; @@ -103,14 +113,16 @@ public class Pathfind{ return vector; } - - public void update(){ - int index = 0; + /**Update the pathfinders and continue calculating the path if it hasn't been calculated yet. + * This method is run each frame.*/ + public void update(){ + + //go through each spawnpoint, and if it's not found a path yet, update it for(SpawnPoint point : Vars.control.getSpawnPoints()){ if(!point.request.pathFound){ try{ - if(point.finder.search(point.request, ms)){ + if(point.finder.search(point.request, maxTime)){ smoother.smoothPath(point.path); point.pathTiles = point.path.nodes.toArray(Tile.class); } @@ -119,12 +131,12 @@ public class Pathfind{ point.request.pathFound = true; } } - - index ++; } } + //1300-1500ms, usually 1400 unoptimized + /**Benchmark pathfinding speed. Debugging stuff.*/ public void benchmark(){ SpawnPoint point = Vars.control.getSpawnPoints().first(); @@ -141,29 +153,22 @@ public class Pathfind{ } UCore.log("Time elapsed: " + Timers.elapsed() + "ms"); } - - public boolean finishedUpdating(){ + + /**Reset and clear the paths.*/ + public void resetPaths(){ for(SpawnPoint point : Vars.control.getSpawnPoints()){ - if(point.pathTiles == null){ - return false; - } - } - return true; - } - - public void updatePath(){ - for(SpawnPoint point : Vars.control.getSpawnPoints()){ - point.finder = new IndexedAStarPathFinder(graph); + point.finder = new OptimizedPathFinder<>(graph); point.path.clear(); point.pathTiles = null; - point.request = new PathFinderRequest(point.start, Vars.control.getCore(), heuristic, point.path); + point.request = new PathFinderRequest<>(point.start, Vars.control.getCore(), heuristic, point.path); point.request.statusChanged = true; //IMPORTANT! } } - + + /**For an enemy that was just loaded from a save, find the node in the path it should be following.*/ void findNode(Enemy enemy){ if(enemy.lane >= Vars.control.getSpawnPoints().size){ enemy.lane = 0; @@ -183,16 +188,9 @@ public class Pathfind{ } enemy.node = closest; - - //TODO - - //Tile end = path[closest]; - //if the enemy can't get to this node, teleport to it - //if(enemy.node < path.length - 2 && Vars.world.raycastWorld(enemy.x, enemy.y, end.worldx(), end.worldy()) != null){ - // Timers.run(Mathf.random(20f), () -> enemy.set(end.worldx(), end.worldy())); - //} } - + + /**Finds the closest tile to a position, in an array of tiles.*/ private static int findClosest(Tile[] tiles, float x, float y){ int cindex = -2; float dst = Float.MAX_VALUE; @@ -209,28 +207,21 @@ public class Pathfind{ return cindex + 1; } - - private static int indexOf(Tile tile, Tile[] tiles){ - int i = -1; - for(int j = 0; j < tiles.length; j ++){ - if(tiles[j] == tile){ - return j; - } - } - return i; - } - + + /**Returns whether a point is on a line.*/ private static boolean onLine(Vector2 vector, float x1, float y1, float x2, float y2){ return MathUtils.isEqual(vector.dst(x1, y1) + vector.dst(x2, y2), Vector2.dst(x1, y1, x2, y2), 0.01f); } - + + /**Returns distance from a point to a line segment.*/ private static float pointLineDist(float x, float y, float x2, float y2, float px, float py){ float l2 = Vector2.dst2(x, y, x2, y2); float t = Math.max(0, Math.min(1, Vector2.dot(px - x, py - y, x2 - x, y2 - y) / l2)); Vector2 projection = Tmp.v1.set(x, y).add(Tmp.v2.set(x2, y2).sub(x, y).scl(t)); // Projection falls on the segment return projection.dst(px, py); } - + + //TODO documentation private static Vector2 projectPoint(float x1, float y1, float x2, float y2, float pointx, float pointy){ float px = x2-x1, py = y2-y1, dAB = px*px + py*py; float u = ((pointx - x1) * px + (pointy - y1) * py) / dAB; diff --git a/core/src/io/anuke/mindustry/ai/TileConnection.java b/core/src/io/anuke/mindustry/ai/TileConnection.java index 26ce5bf0a9..90c7a3d9e7 100644 --- a/core/src/io/anuke/mindustry/ai/TileConnection.java +++ b/core/src/io/anuke/mindustry/ai/TileConnection.java @@ -1,7 +1,6 @@ package io.anuke.mindustry.ai; import com.badlogic.gdx.ai.pfa.Connection; - import io.anuke.mindustry.world.Tile; public class TileConnection implements Connection{ @@ -14,7 +13,7 @@ public class TileConnection implements Connection{ @Override public float getCost(){ - return MHueristic.estimateStatic(a, b); + return HueristicImpl.estimateStatic(a, b); } @Override diff --git a/core/src/io/anuke/mindustry/ai/PassTileGraph.java b/core/src/io/anuke/mindustry/ai/TileGraph.java similarity index 59% rename from core/src/io/anuke/mindustry/ai/PassTileGraph.java rename to core/src/io/anuke/mindustry/ai/TileGraph.java index e04f8471cf..16847be27d 100644 --- a/core/src/io/anuke/mindustry/ai/PassTileGraph.java +++ b/core/src/io/anuke/mindustry/ai/TileGraph.java @@ -1,15 +1,15 @@ package io.anuke.mindustry.ai; import com.badlogic.gdx.ai.pfa.Connection; -import com.badlogic.gdx.ai.pfa.indexed.IndexedGraph; import com.badlogic.gdx.utils.Array; - import io.anuke.mindustry.Vars; import io.anuke.mindustry.world.Tile; -/**Tilegraph that ignores player-made tiles.*/ -public class PassTileGraph implements IndexedGraph{ - private Array> tempConnections = new Array>(); +/**Tilegraph that ignores player-made tiles.*/ +public class TileGraph implements OptimizedGraph { + private Array> tempConnections = new Array>(4); + + /**Used for the default Graph implementation. Returns a result similar to connectionsOf()*/ @Override public Array> getConnections(Tile fromNode){ tempConnections.clear(); @@ -25,9 +25,21 @@ public class PassTileGraph implements IndexedGraph{ return tempConnections; } + /**Used for the OptimizedPathFinder implementation.*/ + @Override + public Tile[] connectionsOf(Tile node){ + Tile[] nodes = node.getNearby(); + for(int i = 0; i < 4; i ++){ + if(nodes[i] != null && !nodes[i].passable()){ + nodes[i] = null; + } + } + return nodes; + } + @Override public int getIndex(Tile node){ - return node.id(); + return node.packedPosition(); } @Override diff --git a/core/src/io/anuke/mindustry/core/Control.java b/core/src/io/anuke/mindustry/core/Control.java index 869e643624..5bb3e877f7 100644 --- a/core/src/io/anuke/mindustry/core/Control.java +++ b/core/src/io/anuke/mindustry/core/Control.java @@ -341,7 +341,7 @@ public class Control extends Module{ Sounds.play("spawn"); if(lastUpdated < wave + 1){ - world.pathfinder().updatePath(); + world.pathfinder().resetPaths(); lastUpdated = wave + 1; } @@ -652,7 +652,7 @@ public class Control extends Module{ wavetime -= delta(); if(lastUpdated < wave + 1 && wavetime < Vars.aheadPathfinding){ //start updating beforehand - world.pathfinder().updatePath(); + world.pathfinder().resetPaths(); lastUpdated = wave + 1; } }else{ diff --git a/core/src/io/anuke/mindustry/core/World.java b/core/src/io/anuke/mindustry/core/World.java index bf67141db7..136994e4eb 100644 --- a/core/src/io/anuke/mindustry/core/World.java +++ b/core/src/io/anuke/mindustry/core/World.java @@ -191,7 +191,7 @@ public class World extends Module{ Vars.control.getTutorial().setDefaultBlocks(control.getCore().x, control.getCore().y); } - pathfind.updatePath(); + pathfind.resetPaths(); } void setDefaultBlocks(){ diff --git a/core/src/io/anuke/mindustry/world/Generator.java b/core/src/io/anuke/mindustry/world/Generator.java index aa1336303f..07fd618d00 100644 --- a/core/src/io/anuke/mindustry/world/Generator.java +++ b/core/src/io/anuke/mindustry/world/Generator.java @@ -85,6 +85,12 @@ public class Generator{ tiles[x][y].setFloor(floor); } } + + for(int x = 0; x < pixmap.getWidth(); x ++){ + for(int y = 0; y < pixmap.getHeight(); y ++) { + tiles[x][y].updateOcclusion(); + } + } if(!hascore){ GameState.set(State.menu); diff --git a/core/src/io/anuke/mindustry/world/Tile.java b/core/src/io/anuke/mindustry/world/Tile.java index eb42890d1d..2d810c9aa5 100644 --- a/core/src/io/anuke/mindustry/world/Tile.java +++ b/core/src/io/anuke/mindustry/world/Tile.java @@ -22,6 +22,8 @@ public class Tile{ * This is relative to the block it is linked to; negate coords to find the link.*/ public byte link = 0; public short x, y; + /**Whether this tile has any solid blocks near it.*/ + public boolean occluded = false; public TileEntity entity; public Tile(int x, int y){ @@ -218,7 +220,20 @@ public class Tile{ } public Tile[] getNearby(Tile[] copy){ - return Vars.world.getNearby(x, y); + return Vars.world.getNearby(x, y, copy); + } + + public void updateOcclusion(){ + occluded = false; + for(int dx = -1; dx <= 1; dx ++){ + for(int dy = -1; dy <= 1; dy ++){ + Tile tile = Vars.world.tile(x + dx, y + dy); + if(tile != null && tile.solid()){ + occluded = true; + break; + } + } + } } public void changed(){ @@ -232,6 +247,8 @@ public class Tile{ if(block.destructible || block.update){ entity = block.getEntity().init(this, block.update); } + + updateOcclusion(); } @Override diff --git a/core/src/io/anuke/mindustry/world/blocks/types/BlockPart.java b/core/src/io/anuke/mindustry/world/blocks/types/BlockPart.java index 8223915c67..d8f93edf51 100644 --- a/core/src/io/anuke/mindustry/world/blocks/types/BlockPart.java +++ b/core/src/io/anuke/mindustry/world/blocks/types/BlockPart.java @@ -22,7 +22,8 @@ public class BlockPart extends Block implements PowerAcceptor, LiquidAcceptor{ @Override public boolean isSolidFor(Tile tile){ - return tile.getLinked().solid() || tile.getLinked().block().isSolidFor(tile.getLinked()); + return tile.getLinked() == null + || (tile.getLinked().solid() || tile.getLinked().block().isSolidFor(tile.getLinked())); } @Override