FX My Life 2.6a: Mazes and...more mazes!
We've got a working grid component (yay, us) but it's not quite adequate for the Dungeon Slippers game. Why? Because the board for this game (and many others) could really benefit from the concept of walls.
Now, a strict grid-based game doesn't really have walls. The earliest computer games couldn't really have supported it. You had your 40 by 25 screen and you had to make do. As character sets have gotten more elaborate, games like Nethack have gotten better at obscuring this fact:
The tile-based version, sort of ironically, exposes this.
You can see how a door and a wall take one full space. This is fine for a lot of games, but the Dungeon Slippers really needs something between the grid spaces.
So, what is a wall? A wall is a border between two cells (or a cell and the edge of the board). It will use an even number of pixels from each side, so it will have to be an even width. (Outside walls will be thinner than inside ones, therefore.)
Now, how are these different from grid-lines? Well, when we drew the grid-lines, we just smacked them down right on top of the cells, we drew them as units (i.e., single horizontal and vertical lines that cross the entire board) and we didn't alter the cell widths or heights. But if we want to fill a cell (and not bleed into a wall), we're going to want the actual dimensions.
So if a cell is 10x10 and we define that our walls are 2 pixels thick, the cell goes to 8x8 (assuming the grid doesn't change size).
This may sound somewhat complicated but it's not, really, because the main tool we're going to be providing our users is the ability to ask for the dimensions of a particular wall. We'll identify a wall by it's top, left coordinate and whether it's horizontal (extending to the right) or vertical (extending downward).
Walls will be uniform size.
Back On The Grid
We haven't touched the grid component in a while, so this should be fun to get back into it. We'll start with a property for wall thickness.
private final SimpleIntegerProperty _wallThickness = new SimpleIntegerProperty(0);
We'll set redraw
up as listener to it:
_wallThickness.addListener(redraw);
And a setter:
public final void setWallThickness(int wt) {_wallThickness.set(wt);}
Now, let's create a test. If wall thickness is 2, any given cell should be have its width and its height reduced by 2: The walls will "borrow" one pixel from the left side and one from the right for width, and one from the top and bottom for height.
@Test
public void colWidthWithWall() {
g.setWallThickness(2);
assertEquals(g.widthProperty().doubleValue()/3-2, g.colWidth(), "Column width should be one third of grid width less two pixels for walls.");
}
This will not work, and not just because we haven't written the code for it yet. We can't set the wall thickness from inside a test thread. JavaFX nodes can only be set from inside the JavaFX thread.
This fellow, Serge Merzliakov, has come up with a class called FXBlock in the interest of not having to pull in a full test suite like TestFX. I'm going to go him one better and just rip off his code (which is actually in Kotlin) and put it in our test class directly for now:
@Test
public void colWidthWithWall() {
var semaphore = new Semaphore(0);
Platform.runLater(new Runnable() {
@Override
public void run() {
g.setWallThickness(2);
semaphore.release();
}
});
try {
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
assertEquals(g.widthProperty().doubleValue()/3-2, g.colWidth(), "Column width should be one third of grid width less two pixels for walls.");
}
We'll revisit this but for now it's fine. The test fails as we expect. Something like:
org.opentest4j.AssertionFailedError: Column width should be one third of grid width less two pixels for walls. ==> expected: <472.6666666666667> but was: <474.6666666666667>
Expected :472.6666666666667
Actual :474.6666666666667
The colWidth
and rowHeight
need to be adjusted for the wall thickness.
Now, if we fix this up in Grid
and run the `colWidthWithWall() test again, it will work:
public double colWidth() {
return widthProperty().doubleValue() / _colCount.get() - _wallThickness.get();
}
But if we run the whole suite of tests over again, the old colWidth
test fails. Or maybe it will fail, may be it won't: In my runs it always failed. If you run colWidth
alone, though, it will pass.
It's not too hard to figure out what's going on there. We're changing the wall thickness and not changing it back, so if the colWidth
goes after colWidthWithWall
, it's gonna plotz.
Man's Reach Should Exceed His Grasp Or What's A Semaphor?
Well, I guess now's a good time to move that FX code into its own routine. In TestApp, we'll create a method called FXit:
public static void FXit(Runnable r) {
var semaphore = new Semaphore(0);
Platform.runLater(new Runnable() {
@Override
public void run() {
r.run();
semaphore.release();
}
});
try {
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
Now we can do this in our colWidthWithWall
code:
@Test
public void colWidthWithWall() {
FXit(() -> {
g.setWallThickness(2);
});
assertEquals(g.widthProperty().doubleValue() / 3 - 2, g.colWidth(), "Column width should be one third of grid width less two pixels for walls.");
FXit(() -> {
g.setWallThickness(0);
});
}
It works, but it's tacky. You don't really want your tests dependent on each other—I mean, unless you do—but you don't want them incidentally dependent on each other. Now, any test might fail if something goes wrong with setWallThickness
or depending on when colWidthWithWall
is run.
Probably the correct thing to do is to set up a fresh grid every test, but we'll compromise for now with:
@Before
public void reset() {
FXit(() -> {
g.setWallThickness(0);
});
}
And:
@Test
public void colWidthWithWall() {
FXit(() -> {
g.setWallThickness(2);
});
assertEquals(g.widthProperty().doubleValue() / 3 - 2, g.colWidth(), "Column width should be one third of grid width less two pixels for walls.");
}
And now that this works, we can do height!
@Test
public void rowHeightWithWall() {
FXit(() -> {
g.setWallThickness(4);
});
assertEquals(g.heightProperty().doubleValue() / 5 - 4, g.rowHeight(), "Row height should be one fifth of grid height less four pixels for walls.");
}
Test fails. So we go write the code to make it work:
public double rowHeight() {
return heightProperty().doubleValue() / _rowCount.get() - _wallThickness.get();
}
And now the test passes.
If we go back to our Dunslip controller and take out the gridlne and borderline code to put in some wall thickness code:
/*int bw = (int)Math.round(Math.random()*20);
System.out.printf("Border width to =>%d\n", bw);
grid1.setBorderThickness(bw);
grid1.setBorderColor(new Color(Math.random(), Math.random(), Math.random(), 1));
int glw = (int)Math.round(Math.random()*3)*2;
System.out.printf("Grid line width to =>%d\n", glw);
grid1.setGridLineThickness(glw);
grid1.setGridLineColor(new Color(Math.random(), Math.random(), Math.random(), 1));
grid1.setHoverColor(new Color(Math.random(), Math.random(), Math.random(), 1));*/
int wt = (int)Math.round(Math.random()*3)*2;
grid1.setWallThickness(wt);
We will immediately notice something odd when we run:
All of the cells are jammed up in the upper-left corner, moreso as the wall thickness gets thicker.
No big surprise, right? All we did was say the columns and rows were smaller, and in most places in the grid, we used those dimensions to figure out where to draw them. But let's step back a moment. After all, we had working code and a lot of other code dependent on it.
Rather than change the working code to include wall thickness, let's create new code and tests and only change the things that are affected. It's so obvious, you wonder why we didn't think of it first!
We'll just return the old methods to as they were and add new ones. In Grid:
public double colWidth() { return widthProperty().doubleValue() / _colCount.get();}
public double rowHeight() { return heightProperty().doubleValue() / _rowCount.get();}
public double wtRowHeight() { return heightProperty().doubleValue() / _rowCount.get() - _wallThickness.get();}
public double wtColWidth() { return widthProperty().doubleValue() / _colCount.get() - _wallThickness.get();}
And the tests can now be:
@Test
public void colWidthWithWall() {
FXit(() -> {
g.setWallThickness(2);
});
assertEquals(g.widthProperty().doubleValue() / 3 - 2, g.wtColWidth(), "Column width should be one third of grid width less two pixels for walls.");
}
@Test
public void rowHeightWithWall() {
FXit(() -> {
g.setWallThickness(4);
});
assertEquals(g.heightProperty().doubleValue() / 5 - 4, g.wtRowHeight(), "Row height should be one fifth of grid height less four pixels for walls.");
}
And we wouldn't even have had to do the whole FXit thing to set the wall thickness, either, since the old routines would not have been affected. We'll need more of these wt*
methods later, as we'll see.
All right, let's comment out all the code in the Dunslip controller's randomize and just replace it with:
grid1.setWallThickness(grid1.getWallThickness()+1);
System.out.printf("Set wall thickness to %d\n", grid1.getWallThickness());
We can run this and while we'll see:
DUNSLIP CONTROLLER ACTIVATED
Set wall thickness to 1
Set wall thickness to 2
Set wall thickness to 3
Set wall thickness to 4
Nothing will actually change as we click, however. We could just use the wt
methods in our inset creation code (remember that?):
public BackgroundFill FillForCoord(int x, int y, Color color) {
return new BackgroundFill(color, CornerRadii.EMPTY,
new Insets(y * wtRowHeight(),
widthProperty().doubleValue() - (wtColWidth() + wtColWidth() * x),
heightProperty().doubleValue() - (wtRowHeight() + wtRowHeight() * y),
x * wtColWidth()));
}
And what we'll see is almost exactly what we saw before: As we click, the cells will shrivel up toward the upper left.
The dimensions of the cells are correct: They should get smaller as the wall thickness gets bigger. But the cells should be drawn centered in the same place.
So, if we just use the regular rowHeight
and colWidth
but then adjust for the wall thickness:
public BackgroundFill FillForCoord(int x, int y, Color color) { return new BackgroundFill(color, CornerRadii.EMPTY, new Insets(y rowHeight() + _wallThickness.get(), widthProperty().doubleValue() - (colWidth() + colWidth() x) + _wallThickness.get(), heightProperty().doubleValue() - (rowHeight() + rowHeight() y) + _wallThickness.get(), x colWidth() + _wallThickness.get())); }
And eureka!
Now, to be usable, we may have to put that coordinate calculation code into its own routine, and we'll certainly need to provide something for the wall coordinates. Just as it was helpful to develop the grid by integrating it into our tic-tac-toe game, we'll go on another simple side-quest to figure out how we might best address walls.
A Maze: A section header without the obvious pun (or is it?)
Back in the day, when we were short on RAM (48K for everything, including the screen!) we would make mazes with the following algorithm:
- Start with a random cell.
- Connect to a random unconnected (to anything) neighbor.
- If the neighbor has unconnected neighbors, go back to step 1.
- If it does not, find a random cell with unconnected neighbors, then step 1.
- Unless all cells are connected, in which case you're done.
To save space, we would toggle bits: The first bit would be up (or north), the second would be right, the third would be down, and the fourth would be left. I don't recall if we actually used nibbles (or "nybbles" as Apple called them) to store two cells in one byte. I suppose some may have.
No one ever used the four remaining bits to do diagonal connections. That would be madness. In any event it's beyond our current scope, so let's just do the above algorithm to make a maze of right angles.
Addressing Walls
Our BasicMaze
class thus begins:
public class BasicMaze {
private final int width;
private final int height;
private byte[][] maze;
public enum Direction {UP, RIGHT, DOWN, LEFT};
We'll do a two-dimensional array of bytes, such that maze[0] represents the first row and maze[0][0] is the first column in the first row. Storing 2D info in rows worked out pretty handily for tic-tac-toe—I just have to remember to address coordinates as maze[y][x]
. Let's see if I can pull that off this time.
I added the directions enum on a lark. Let's see if we find a use for them.
Our constructer will be very simple:
BasicMaze(int w, int h) {
width = w;
height = h;
}
We might or might not decide to initialize the array up here later on, if it simplifies access. (Like, we might not want to have to check for a null maze
.) But we're not going to generate it in the constructor. Why? Because it can be fun to watch mazes being generated, which we can't do if we're starting with it already generated.
We'll steal the same gag we used in tic-tac-toe for Consumer
s:
private transient List<Consumer<BasicMaze>> consumers = new ArrayList<>();
. . .
public void addConsumer(Consumer<BasicMaze> l) {
if (consumers == null) consumers = new ArrayList<>();
consumers.add(l);
}
public void removeConsumer(Consumer<BasicMaze> l) {
consumers.remove(l);
}
public void alertConsumers() {
consumers.forEach(c -> c.accept(this));
}
Now to generate the maze. We'll use the algorithm above, though it needs to be tightened a bit.
- Start with a random cell.
- Connect to a random unconnected (to anything) neighbor.
- If the neighbor has unconnected neighbors, go back to step 1.
- If it does not, find a random cell with unconnected neighbors, then step 1.
- Unless all cells are connected, in which case you're done.
I feel like "start with a random cell" is going to come up a lot, so I'll make that a function in the Coord
class:
public static Coord RandCoord(int aX, int aY) {
return(new Coord((int)(Math.random()*aX), (int)(Math.random()*aY)));
}
We start like so:
public void generate() {
int slots = width * height;
maze = new byte[height][width];
Coord c = RandCoord(width, height);
do {
. . . //connect the cell
slots--;
} while (slots > 1);
slots
is the number of cells we have to connect, so as long as it's greater than one, we've got a cell out there that's orphaned. (It's not zero because the first cell we connect doesn't get counted.) This fragment is basically steps 0 and 4.
Now we have to pick a random direction. In past iterations, the easiest way of doing this was just to randomly pick one of the four directions, and if the cell already had a path, randomly pick again, etc., until you got a previously unused path.
The thing about picking a random coordinate (as above) or a random direction is that you end up increasingly picking ineligible coordinates or directions as the maze gets built. If you had a 100x100 maze with one unconnected cell, well, you have a 1 in 10,000 chance of picking it at random.
This could sometimes end up being very slow. I doubt it will matter much for our purposes, but I kind of liked the gag we used in the tic-tac-toe game with the random auto-player: Make a list of eligible spots and pick randomly from that.
We want to do something like this:
var l = new ArrayList<Coord>();
if (maze[c.y-1][c.yx]==0)
l.add(c);
Almost, but not quite, because if c.y
is 0 we'll be looking at maze[-1][whatever]
which is a no-no. Well, what if we added a function?
if (neighbor(c, UP)==0)
l.add(c);
I like using the Direction
enum here over passing in something like neighbor(c, 0, -1)
, where [0, -1] is basically "UP". Now let's make a local function, neighbor
:
BiFunction<Coord, Direction, Byte> neighbor = (co, dir) -> {
int mx = 0;
int my = 0;
switch (dir) {
case UP -> my = -1;
case DOWN -> my = 1;
case RIGHT -> mx = 1;
case LEFT -> mx = -1;
}
Coord nc = co;
nc.x = nc.x + mx;
nc.y = nc.y + my;
if (nc.y < 0 || nc.y >= height || nc.x < 0 || nc.x >= width) return null;
else return maze[nc.y][nc.x];
};
The neighbor
code is fine, though we may want to move it into a real method. It looks a little ugly standalone:
if (neighbor.apply(c, UP) != null && neighbor.apply(c, UP) == 0)
l.add(c);
Still, we can use it to test all the directions in one swoop:
do {
var l = new ArrayList<Direction>();
for (Direction dir : Direction.values()) {
Byte n = neighbor.apply(c, dir);
if (n != null && n == 0)
l.add(dir);
}
And then to wrap it up, we'll need to connect and move on to the next coordinate, which I think gives us a chance to simplify and also make the code safer:
if (l.size() > 0) {
var d = l.get((new Random()).nextInt(l.size()));
c = connect(c, d);
slots--;
} else do {
c = RandCoord(width, height);
} while (maze[c.y][c.x] == 0);
I lifted the random from the tic-tac-toe game, but we have an advantage: At this point, we know whether the cell is valid for the operation because if it's not, it has no unconnected neighbors.
So we just pick a random direction (from the list of known valid ones), then connect somehow and move to the newly connected cell (we'll have to write that method), finally decrementing the counter . If there are no valid directions, we'll just pick a random (connected) coordinate and try from there.
Before we get to the actual connection code, it's interesting to note that moving the coordinate:
BiFunction<Coord, Direction, Coord> connect = (co, dir) -> {
int mx = 0;
int my = 0;
switch (dir) {
case UP -> my = -1;
case DOWN -> my = 1;
case RIGHT -> mx = 1;
case LEFT -> mx = -1;
}
return new Coord(co.x+mx, co.y+my);
};
Is awful darn close to what we have in neighbor
. IntelliJ wants to take the whole function and put it as a method but I don't think that works for us. If we extract this, however:
public Coord dirToDelta(Direction dir) {
int mx = 0;
int my = 0;
switch (dir) {
case UP -> my = -1;
case DOWN -> my = 1;
case RIGHT -> mx = 1;
case LEFT -> mx = -1;
}
return new Coord(mx, my);
}
That pays off some nice dividends in neighbor
:
BiFunction<Coord, Direction, EnumSet<Direction>> neighbor = (co, dir) -> {
Coord delta = dirToDelta(dir);
Coord dest = new Coord(co.x + delta.x, co.y + delta.y);
if (dest.y < 0 || dest.y >= height || dest.x < 0 || dest.x >= width) return null;
else return maze[dest.y][dest.x];
};
I decided to call the outgoing coordinate dest
for clarity. connect
is also improved:
BiFunction<Coord, Direction, Coord> connect = (co, dir) -> {
Coord dc = dirToDelta(dir);
return new Coord(co.x+dc.x, co.y+dc.y);
};
Now we gotta actually do the connecting, though.
A typical Java approach would be to create a cell class and give it properties of "connected[Direction]", or we could even make our array, instead of being bytes, of an EnumSet
based on the Direction
.
The direct bit handling calls to me. There's nothing like working with real 0s and 1s. The EnumSet
on the other hand is (I'm told) bits underneath with just a little bit of sugar. And I haven't used one before, so I guess I'll give it a try:
private EnumSet<Direction>[][] maze;
Initializing maze
gets ugly:
maze = (EnumSet<Direction>[][]) new EnumSet<?>[width][height];
Nope, even uglier than that, because we can't just use 0 as with bytes. We need to actually create an empty EnumSet object in each cell location. (No way is this comparable in memory to an array of bytes.) Of the nigh-infinite approaches to initializing an array, I've not found a clearer one for 2D arrays than this:
for (int j = 0; j < height; j++)
for (int i = 0; i < width; i++)
maze[j][i] = EnumSet.noneOf(Direction.class);
Now, we have to change the return type on neighbor
:
BiFunction<Coord, Direction, EnumSet<Direction>> neighbor = (co, dir) -> {
And instead of testing for 0, we'll have to test for isEmpty
:
if (n != null && n.isEmpty())
. . .
} while (maze[c.y][c.x].isEmpty());
The whole routine now looks like:
public void generate() {
int slots = width * height;
maze = (EnumSet<Direction>[][]) new EnumSet<?>[height][width];
for (int j = 0; j < height; j++)
for (int i = 0; i < width; i++)
maze[j][i] = EnumSet.noneOf(Direction.class);
BiFunction<Coord, Direction, EnumSet<Direction>> neighbor = (co, dir) -> {
Coord delta = dirToDelta(dir);
Coord dest = new Coord(co.x + delta.x, co.y + delta.y);
if (dest.y < 0 || dest.y >= height || dest.x < 0 || dest.x >= width) return null;
else return maze[dest.y][dest.x];
};
BiFunction<Coord, Direction, Coord> connect = (src, dir) -> {
Coord dc = dirToDelta(dir);
Coord dst = new Coord(src.x + dc.x, src.y + dc.y);
return dst;
};
Coord c = RandCoord(width, height);
do {
var l = new ArrayList<Direction>();
for (Direction dir : Direction.values()) {
EnumSet<Direction> n = neighbor.apply(c, dir);
if (n != null && n.isEmpty())
l.add(dir);
}
if (l.size() > 0) {
var d = l.get((new Random()).nextInt(l.size()));
c = connect.apply(c, d);
slots--;
} else do {
c = RandCoord(width, height);
} while (maze[c.y][c.x].isEmpty());
} while (slots > 1);
}
But this should, at least in theory, make our connect
function clearer. If you've never worked with bit operations, you may not appreciate this, but to connect, we now just do this:
BiFunction<Coord, Direction, Coord> connect = (src, dir) -> {
Coord dc = dirToDelta(dir);
Coord dst = new Coord(src.x + dc.x, src.y + dc.y);
maze[src.y][src.x].add(dir);
maze[dst.y][dst.x].add(compDir(dir));
return dst;
};
That's pretty clean! We get the delta and create our destination coordinate, then we add the direction to the source's enum set, add the complement of the direction (DOWN as the complement of UP, eg.) to the destination enum set, and return the new coordinate.
CompDir could look like this:
Function<Direction, Direction> compDir = (dir) -> switch (dir) {
case UP -> Direction.DOWN;
case DOWN -> Direction.UP;
case RIGHT -> Direction.LEFT;
case LEFT -> Direction.RIGHT;
};
and then the line above using it would be changed to:
maze[dst.y][dst.x].add(compDir.apply(dir));
Yeah, But Does Any Of This Actually Work?
Well, we have gone far, far afield of a test-driven approach. Not only that we've buried three functions in our generate
method that we can't test directly—an interesting side-effect of lambdas.
Let's make a launch point to try some of this out. Bring up "tilespanel.fxml" in SceneBuilder and add a new tile:
In the resources directory, create a new sub-directory—we'll call it "maze" and create a new FXML, "amaze.fxml" which you can create in SceneBuilder with just a single stackpane—much like we did for Dungeon Slippers, but even simpler. Then make some edits, like we did before:
<Grid fx:id="grid" colCount="9" rowCount="6" maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/11.0.1" xmlns:fx="http://javafx.com/fxml/1" fx:controller="fxgames.basicmaze.MazeController"/>
Make the fx:id "grid" if you didn't in the SceneBuilder. Add the colCount
and rowCount
properties, and add the fx:controller field.
In order to make that work, of course, you'll need to create MazeController
in the already existing "fxgames.basicmaze". In the main Controller
, you'll need to add:
loadNode("maze", "/resources/maze/amaze.fxml");
alongside the other calls to loadNode
. In the TileController
, you'll need to add a new method:
public void openMaze(MouseEvent mouseEvent) {openGame("maze");}
Remember to hook openMaze
up to the graphic you added to the TileController
in the SceneBuilder. MazeController
, like DunslipController
before it, will be just a shell:
package fxgames.basicmaze;
import fxgames.Grid;
import javafx.fxml.FXML;
public class MazeController {
@FXML
Grid grid;
@FXML
public void initialize() {
System.out.println("MAZE CONTROLLER ACTIVATED");
}
}
We're just going to see if everything works by fudging some stuff into the initialize.
For giggles, I'm going to make the grid very plain:
grid.setColorPattern(new ArrayList<Color>(Arrays.asList(Color.web("0x777777",1.0), Color.web("0x7F7F7F",1.0))));
grid.setGridLineThickness(0);
grid.setBorderThickness(0);
Even though we're not using it yet, when we do pull it up, it'll look like:
which is more suited to our maze than the bright red/blue pattern that is default. We don't want grid lines or borders because they'll mess up our walls. Now, let's generate a maze:
BasicMaze maze = new BasicMaze(7, 5);
maze.generate();
Well. Great. Now what? Did it work?
Before we get into the walls stuff, let's make sure our little maze algorithm works out by giving the maze the ability to print itself. This isn't going to be pretty, but we'll save pretty for when we actually make the grid version.
In BasicMaze
, add a print method:
public void print() {
for (int j = 0; j < height; j++) {
for (int i = 0; i < width; i++) {
System.out.print((maze[j][i].contains(Direction.UP)) ? "·░·" : "·─·");
}
System.out.println();
for (int i = 0; i < width; i++) {
System.out.print((maze[j][i].contains(Direction.LEFT)) ? "░" : "|");
System.out.print("░");
System.out.print((maze[j][i].contains(Direction.RIGHT)) ? "░" : "|");
}
System.out.println();
if (j == (height - 1)) {
for (int i = 0; i < width; i++) {
System.out.print((maze[j][i].contains(Direction.DOWN)) ? "·░·" : "·─·");
}
System.out.println();
}
}
}
There are three lines to print out for every row of the maze: Print out an open space when it's allowed for the cell to be exited up, otherwise use a horizontal line for a wall; Print out either a dot-pattern or a vertical line for right and left access, with the dot pattern between them; last, if we're at the bottom of the maze, print out the bottom.
We don't actually need the conditional, I suppose, since the bottom by definition has to be all walls. (Unless we add an escape hatch at some point.)
So now:
BasicMaze maze = new BasicMaze(7, 5);
maze.generate();
maze.print();
Gets us a picture of a maze when we fire up the app:
·─··─··─··─··─··─··─·
|░░░░||░||░░░░░░░░░░|
·░··░··░··░··░··─··░·
|░||░||░░░░||░||░||░|
·░··░··─··─··░··░··░·
|░||░░░░░░░||░░░░||░|
·░··░··─··░··─··─··░·
|░||░||░||░||░░░░||░|
·░··─··░··░··░··░··░·
|░░░░░░░||░░░░||░░░░|
·─··─··─··─··─··─··─·
As you can see, every location has only one path.
We're golden!
Now we can actually do our walls!
Code is available here .