FX My Life 2.6b: Still More Mazes?!

·

18 min read

When we last left our maze project, we had generated a maze and could display it on the console printout, crudely, like a primitive cave-programmer might have chiseled on his wall. But we also left room for the walls, and now we can draw them graphically. (Feel free to "ooh" and "aah" at this point.)

Let's start by adding a wall thickness and seeing how that looks:

public void initialize() {
        System.out.println("MAZE CONTROLLER ACTIVATED");
        grid.setColorPattern(new ArrayList<Color>(Arrays.asList(Color.web("0x777777",1.0), Color.web("0x7F7F7F",1.0))));
        grid.setGridLineThickness(0);
        grid.setBorderThickness(0);

        grid.setWallThickness(6); //<--add this

        BasicMaze maze = new BasicMaze(7, 5);
        maze.generate();
        maze.print();
    }

This results in:

image.png

The walls will be nicely visible when we do finally draw them in. But with our wall spaces there, we're seeing the entire FXGames background color—that sort of mildly-functional-looking Post-It color—between the squares. So perhaps we need a background color for Grid. We're pretty good at this sorta thing by this point, right?

In Grid:

    private final SimpleObjectProperty<Color> _backgroundColor = new SimpleObjectProperty<>(Color.BLUE);

And in the constructor:

_backgroundColor.addListener(redraw);

In setBackgrounds, we need to build the background fill and make it the first fill in the list. Every other inset we make should go over it.

        BackgroundFill[] fills = new BackgroundFill[rows * cols + 2];
        fills[0] = new BackgroundFill(_backgroundColor.get(), CornerRadii.EMPTY, new Insets(0, 0, 0, 0));
        for (int i = 0; i < cols; i++)
            for (int j = 0; j < rows; j++) {
                Color color = colorPattern.get(((i % numColors) + j) % numColors);
                fills[j + (i * rows) + 1] = FillForCoord(i, j, color);
            }
        if (hoverCoord != null) {
            fills[rows * cols + 1] = FillForCoord(hoverCoord.x, hoverCoord.y, _hoverColor.get());
        }
        this.setBackground(new Background(fills));

Note the subtle changes in the fill array:

        BackgroundFill[] fills = new BackgroundFill[rows * cols + 2];
. . .
                fills[j + (i * rows) + 1] = FillForCoord(i, j, color);
. . .
    fills[rows * cols + 1] = FillForCoord(hoverCoord.x, hoverCoord.y, _hoverColor.get());
. . .

as we have to shuffle everything down a slot in the array. And lastly:

    public final void setBackgroundColor(Color c) {_backgroundColor.set(c);}

OK, this produces, by default:

image.png

If we go to the MazeController, we can set the color to halfway between the two grid colors we're using:

grid.setBackgroundColor(Color.web("0x7B7B7B"));

And we'll get:

image.png

Which is kinda nice.

At Last! Walls!

I think we've put this off as long as we can. We can probably get a good idea of how to proceed by looking at the tic-tac-toe view-model controller. (You see? That wasn't a pointless digression!)

Let's make a class called BmController. Now, our TttController looked like this:

public class TttViewModel {

    private final TicTacToe game;
    private final Grid board;
    private final TttController cont;

    public TttViewModel(Grid g, TicTacToe t, TttController c) {
        game = t;
        board = g;
        cont = c;

        g.setOnGridDragMove((var1, x, y) -> game.canAccept(dragBoardToPiece(var1), x, y));

        g.setOnGridDragDrop((var1, x, y) -> {
            game.addPiece(dragBoardToPiece(var1), x, y);
            draw();
        });

        game.addConsumer((game) -> this.draw());
    }

We can strip this down for our maze-ing purposes:

package fxgames.basicmaze;

import fxgames.Grid;

public class MazeViewModel {
    private final BasicMaze game;
    private final Grid board;
    private final MazeController cont;

    public MazeViewModel(Grid g, BasicMaze m, MazeController c) {
        game = m;
        board = g;
        cont = c;

        game.addConsumer((game) -> this.draw());
    }

    public void draw() {
        board.piecePane.getChildren().clear();
    }

}

I've stubbed out the draw method, basically. And we connect it up in MazeController like so:

        BasicMaze maze = new BasicMaze(7, 5);
        maze.generate();
        mvm = new MazeViewModel(grid, maze, this);

Now we just need to make the draw method, you know, draw. The Grid should be able to tell us the dimensions of any particular wall or vertex—but how do we best express this?

Well, let's see, what about something like:

public Rectangle getWallDimensions(Coord c, Coord d)

? It's okay, I guess. I kind of like the idea of passing in two coordinates, except they wouldn't even have to be adjacent, so we'd have to check for that. Meh. What about something like we have for getAxesVal:

public int getAxisVal(double w, boolean isX)

Maybe:

public Rectangle getWallDim(Coord C, boolean isHorz, boolean isPlus)

So, any given cell can have a wall on one of four sides. The user passes in the coordinate and if isHorz is true, they want either the top (isPlus is false) or bottom, otherwise left or right.

Before rolling it out, let's consider how it would work. We can probably use our existing print routine as a clue:

  1. Draw all the "tops" for each row.
  2. Draw the "bottoms" for the bottom row.
  3. Draw all the "lefts" for each column.
  4. Draw the "rights" for the right column.

Something like:

public void draw() {
        board.piecePane.getChildren().clear();

        for (int j = 0; j < height; j++) {
            for (int i = 0; i < width; i++) {
                if (!maze[j][i].contains(BasicMaze.Direction.UP))
                    drawWall(TOP);
                if (j = (height - 1)) drawWall(BOTTOM);
                if (!maze[j][i].contains(BasicMaze.Direction.LEFT))
                    drawWall(LEFT);
                if (i = (width - 1)) drawWall(RIGHT);
            }
        }
    }

Of course, we're completely eliding the "drawWall" and the directions need to be replaced with actual dimensions, but it's a start. Let's see if our propoused getWallDim would work here:

        for (int j = 0; j < game.getHeight(); j++) {
            for (int i = 0; i < game.getWidth(); i++) {
                if (!game.get(j, i).contains(BasicMaze.Direction.UP))
                    drawWall(board.getWallDim(new Coord(i, j), true, false));
                if (j == (game.getHeight() - 1)) drawWall(board.getWallDim(new Coord(i, j), true, true));
                if (!game.get(j, i).contains(BasicMaze.Direction.LEFT))
                    drawWall(board.getWallDim(new Coord(i, j), false, false));
                if (i == (game.getWidth() - 1)) drawWall(board.getWallDim(new Coord(i, j), false, true));
            }
        }

Eh. Not exactly pretty with all the calls we have to make (to get properties) but it'll get the job done and it's not hard to understand.

Implementation Details

We don't actually want to return a Rectangle from our getWallDim, because a Rectangle is a specific kind of JavaFX node when all we want is a description of the bounding area. That class is Rectangle2D.

public Rectangle2D getWallDim(Coord C, boolean isHorz, boolean isPlus) {
        return r;
    }

Now we just have to figure out what r should be. There will be a slight wrinkle (as always, amirite?) but let's take the "naive" approach, as they say. We added, from our TttViewModel, a couple of calls we used to place our Xs and Os:

    public final double cellLocalX(int i) {
        return i * cellWidth();
    }
    public final double cellLocalY(int j) {
        return j * cellHeight();
    }

The top left corner of the square. We can use this to figure out where the walls should go. Roughly:

    public Rectangle2D getWallDim(Coord c, boolean isHorz, boolean isPlus) {
        double x, y, w, h;
        if(isHorz & !isPlus) {x = cellLocalX(c.x); y = cellLocalY(c.y); w = cellWidth(); h = _wallThickness.get();}
        else
        if(isHorz & isPlus) {x = cellLocalX(c.x); y = cellLocalY(c.y) + rowHeight() - _wallThickness.get(); w = cellWidth(); h = _wallThickness.get();}
        else
        if(/*!isHorz &*/ !isPlus) {x = cellLocalX(c.x); y = cellLocalY(c.y); w = _wallThickness.get(); h = rowHeight();}
        else
        /*if(!isHorz & isPlus)*/ {x = cellLocalX(c.x) + colWidth() - _wallThickness.get(); y = cellLocalY(c.y); w = _wallThickness.get(); h = rowHeight();}
        return new Rectangle2D(x, y, w, h);
    }

if isHorz and !isPlus they're asking about the top wall, which we figure out by starting in the upper left and extending to the right. The others are similarly calculated; I've commented out parts of the last two conditionals because they follow naturally from the first two and are, therefore, unnecessary—but I like to see it spelled out.

To make our MazeViewController draw code work, though, we need to add a few functions to BasicMaze:

    public int getHeight() {
        return height;
    }

    public int getWidth() {
        return width;
    }

    public EnumSet<Direction> get(int y, int x) {
        if (maze == null) return EnumSet.noneOf(Direction.class);
        return maze[y][x];
    }

The EnumSet always returns something you can check. The maze field doesn't have a value until generate is called, so this will return walls everywhere. If we run the code, though, we won't see anything: draw will never be called!

We didn't have this problem with Tic-Tac-Toe! What's going on?

Well, we didn't actually draw anything with Tic-Tac-Toe until pieces are dropped on the board. The board itself is taken care of by basic Grid functionality. The MazeViewController is created right away by the MazeController, at which time the Grid is essentially non-existent.

Then when the game is actually brought up, there's nothing to tell it to draw itself. We need a listener!

    public MazeViewModel(Grid g, BasicMaze m, MazeController c) {
        game = m;
        board = g;
        cont = c;

        game.addConsumer((game) -> this.draw());
        InvalidationListener l = new InvalidationListener() {
            @Override
            public void invalidated(Observable observable) {
                draw();
            }
        };

        g.widthProperty().addListener(l);
        g.heightProperty().addListener(l);
    }

Now, if we fire the game up, we'll see:

image.png

Which is sorta right—except for not having enough walls! Well, you know, we never actually connected the number of Grid cells to the Maze cells!

    public MazeViewModel(Grid g, BasicMaze m, MazeController c) {
        game = m;
        board = g;
        cont = c;

        g.setColCount(m.getWidth());
        g.setRowCount(m.getHeight());

I'm not sure where these calls will end up: Seems like we'll probably want a way to make new mazes, and the hook up should go there. But this gets us to this point:

image.png

Walls around all the cells! Note, however, that the walls aren't quite filling the space. The gray area around the yellow highlighted square should also be walls but we're only building the top and left walls, except along the bottom and right edges when we include those walls, too.

What's The Coordinate, Kenneth?

We could, of course, build the bottom and right walls, and for a case like this basic maze, it's not really an issue. But for most cases—and certainly for "Dungeon Slippers", if you still remember that—a wall's coordinates should be the combination of the joined walls.

In other words, if I ask for the coordinates of the wall between [0, 0] and [0, 1], I should get the wall that starts at the bottom left of [0, 0] and ends at the top right of [0, 1].

The further wrinkle is that I should be able to get (or not get) the vertices. If I'm asking for any of the common walls between [0, 0], [0, 1], [1, 0] and [1, 1], in other words, I should be able to avoid getting: image.png the area represented by the red dot.

We won't worry about the vertices here, but we definitely need to get the full wall dimensions. This is a little trickier than it seems at first, especially the way our code is currently set up.

If we're getting a top wall, we need the bottom of the neighbor upstairs. If we're getting a right wall, we need the left wall of the neighbor to the right. And if we're on the edge we're looking at, we do nothing. No, I'm not liking this at all. If we work with our current code, we'd take whatever we already have for dimensions, check for horizontal and vertical (again), check for edges, add or subtract depending on isPlus...no, no, no, this won't do.

Let's do it more like this:

public Rectangle2D getWallDim(Coord c, boolean isHorz, boolean isPlus) {
        double x, y, w, h;
        int wt = _wallThickness.get();
        if (isHorz) {
            y = cellLocalY(isPlus ? c.y + 1 : c.y) - (!isPlus && (c.y==0) ? 0 : wt);
            x = cellLocalX(c.x);
            w = cellWidth();
            h = ((!isPlus && (c.y == 0)) || ((isPlus && (c.y == _rowCount.get() - 1)))) ? wt : wt * 2;
        } else { //not horizontal
            x = cellLocalX(isPlus ? c.x + 1 : c.x) - (!isPlus && (c.x==0) ? 0 : wt);
            y = cellLocalY(c.y);
            h = cellHeight();
            w = ((!isPlus && (c.x == 0)) || ((isPlus && (c.x == _colCount.get() - 1)))) ? wt : wt * 2;
        }
        return new Rectangle2D(x, y, w, h);
    }

This is one of those blocks of code it's easier to mentally visualize than describe, but it's fairly simple: We always figure as "left" and "top" walls where possible, and with double-thickness. So for horizontals:

  1. If you're asking for a "bottom" we give you the "top" for the next row down.
  2. We have to check if you're asking for the "top" and you're on the top row because you're getting a double-thick wall otherwise.
  3. Your x and width are unmodified.
  4. The height of the wall is double, again unless you're at the top or bottom.

Verticals are handled in a complementary fashion. The results look like we'd want:

image.png

Note, though, we haven't really tested requests for bottom and right walls that aren't on the edge. That may come back to bite us later. (And by "bite", I just mean, "if we don't test it now, we'll have to debug it later.")

Well, what if we put this at the bottom of the maze view controller constructor?

        game.addConsumer((game) -> this.draw());
        game.generate();

Well!

image.png

Hmmm. Looks like our vertices are showing...

image.png

But before we fix that, let's make the maze re-generate when we click on it.

Live Nude Mazes!

Borrowing a trick we used first in dunslip.fxml, add an onMouseClicked to amaze.fxml:

<Grid fx:id="grid" colCount="9" rowCount="6"  onMouseClicked="#randomize"...

Now in MazeController:

    @FXML
    public void randomize() {
        maze.reset(15, 15);
        maze.generate();
    }

And, yeah, we're gonna need a reset function in BasicMaze:

    public void reset(int w, int h) {
        width = w;
        height = h;
        maze = null;
        alertConsumers();
    }

OK, cool. Whenever we click on the maze, we get a new one:

image.png

image.png

image.png

Which is nice, and all, but I want to see the maze being built! Why aren't we getting updates?

Well, we're building the maze on the same thread that's used to update the screen—the main JavaFX thread. If we want to see updates mid-thread, we'll have to run the process separately and update the JavaFX thread somehow.

This is actually pretty easy, and reveals some interesting things about JavaFX's architecture.

Threads, Threads, Who's Got The Threads?

Let's make some minor changes for aesthetic purposes. Let's make the walls thinner in MazeController:

grid.setWallThickness(2);

And when we draw the wall, let's use a color other than black:

public void drawWall(Rectangle2D r) {
        Rectangle w = new Rectangle(r.getMinX(), r.getMinY(), r.getWidth(), r.getHeight());
        w.setFill(Color.web("0x353535", 1.0));
        board.piecePane.getChildren().add(w);
    }

This will produce a final effect of:


![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1631566318321/QmrjsArUi.png)

For a 30x20 maze. (The imperfect wall dimensions actually look kind of nice at this scale, like we were deliberately rounding. Heh.) The 30x20 dimensions worked well for my experiments but based on your setup, you may want to use a larger or smaller maze.

To get the maze to show up as it is being built, we need to do two things, plus a cheat. First, we need to start the maze generation on its own thread.

@FXML
    public void randomize() {
        //maze.reset((int) (Math.random() * 40 + 40), (int) (Math.random() * 40 + 25));
        maze.reset(30, 20);
        mvm.calls = 0;
        new Thread(() -> {
            maze.generate();
        }).start();
    }

And we need to call the draw method using Platform.runLater (in the MazeViewModel's constructor:

game.addConsumer(basicMaze -> Platform.runLater(this::draw));

And that'll do it. Alllmost. You might or you might not see your maze being built. Why?

What runLater does is add commands to a queue (to be run later, and in the case of JavaFX, when it gets around to it.) But even our grossly inefficient maze-generation code goes so fast on these darn modern computers we just end up with a pile of events in the queue that don't actually get run until the very end of the maze generation.

To illustrate the point, let's make the BasicMaze's slots local variable into a public field:

public class BasicMaze {
    private int width;
    private int height;
    public int slots;

We can make a variable for the MazeController called calls, which we can t hen print out as we go:

 public void draw() {
        board.setRowCount(game.getHeight());
        board.setColCount(game.getWidth());
        board.piecePane.getChildren().clear();
        calls ++;
        System.out.printf("%d games slots on cycle %d\n",game.slots, calls);

When you run this, you should see something like this:

1 games slots on cycle 1
1 games slots on cycle 2
1 games slots on cycle 3
1 games slots on cycle 4
1 games slots on cycle 5
1 games slots on cycle 6
1 games slots on cycle 7
1 games slots on cycle 8
1 games slots on cycle 9
1 games slots on cycle 10
. . .

And for a 30x20 maze, you'll get 600 of these calls, all after the maze has been fully generated. And these calls will continue after you've shut the GUI down, even, until all 600 calls are made.

Now, if we slow the maze building down a bit, it's a different story. In the generate code, if we add asleep`:

            if (l.size() > 0) {
                var d = l.get((new Random()).nextInt(l.size()));
                c = connect.apply(c, d);
                alertConsumers();
               try {
                    sleep(25);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                slots--;
            } else do {
                c = RandCoord(width, height);
            } while (maze[c.y][c.x].isEmpty());
        } while (slots > 1);

Now we will see something more like the following:

600 games slots on cycle 1
600 games slots on cycle 2
599 games slots on cycle 3
598 games slots on cycle 4
597 games slots on cycle 5
596 games slots on cycle 6
595 games slots on cycle 7
594 games slots on cycle 8
593 games slots on cycle 9
592 games slots on cycle 10

And we can see our maze being built!

maze.gif

Well, that's fun! N.B. our application is still active—we haven't locked up the UI, which is nice. The value you set for sleep to get a good visual is going to depend on a lot of local factors, as mentioned. I find that if I set the value to 10, I get a smooth, faster visualization of the maze being generated.

But we don't really want to, you know, have a sleep element in our code, right? We don't have to sleep per se. Like, we could just replace the try block above with:

print();

And our maze generation will be visualized textually as well as graphically. But it amounts to the same thing: We're slowing our code down to accommodate the graphics display. Since games are often about timing, you could make a justification for burning cycles, maybe—but we're just kind of dumbly slowing things down.

So where does this leave us?

Premature Optimization Is The Route Of All Evil

There are two valuable takeaways from this experiment: If we really want to animate maze generation (or anything) in JavaFX, the solution is probably to store all the states that we want to animate and then use the animation features of JavaFX completely independently of the background generation.

I don't think I want to devote time to doing a proper animation just now, so let's just do a little optimization on the maze drawing. It's not hard to figure out why it's so slow: It draws the entire map every time, even if only wall has changed. Let's add a consumer that listens for when two cells have been connected. In BasicMaze:

private transient List<Consumer<CoordPair>> updaters = new ArrayList<>();

We're gonna need a CoordPair object, too:

package fxgames;

public class CoordPair {
    private final Coord c1;
    private final Coord c2;

    public CoordPair(Coord c, Coord d) {
        c1 = new Coord(c.x, c.y);
        c2 = new Coord(d.x, d.y);
    }

    @Override
    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof CoordPair)) {
            return false;
        }
        CoordPair cp = (CoordPair) o;
        return (cp.c1.equals(c1) && cp.c2.equals(c2)) ||
                (cp.c1.equals(c2) && cp.c2.equals(c1));
    }

    @Override
    public int hashCode() {
        return c1.x*c2.x*c1.y*c2.y;
    }
}

In order to make the custom hash-map work, we have to create a hashCode that relies on the values inside the coordinates (though this one isn't particularly good, I suppose, it's good enough). And we have to make our own equals, which we'll look at shortly.

When a wall is removed between two Coords, we'll call our updaters:

    public void addUpdater(Consumer<CoordPair> cp) {
        if (updaters == null) updaters = new ArrayList<>();
        updaters.add(cp);
    }

    public void alertUpdaters(CoordPair cp) {
        updaters.forEach(c -> c.accept(cp));
    }
. . .
            if (l.size() > 0) {
                var d = l.get((new Random()).nextInt(l.size()));
                var c2 = connect.apply(c, d);
                alertConsumers();
                alertUpdaters(new CoordPair(c, c2)); //<--add this

In the MazeVewModel, we'll keep track of all the walls we create:

private HashMap<CoordPair, Rectangle> walls;

We'll set up the hash-map in the draw method of MazeViewModel:

    public void draw() {
        walls = new HashMap<CoordPair, Rectangle>();

Since we're going to need more than just creating a Rectangle and add it, let's wrap the old drawWall method in a method that can also update the map:

        for (int j = 0; j < game.getHeight(); j++) {
            for (int i = 0; i < game.getWidth(); i++) {
                if (!game.get(j, i).contains(UP))
                    addWall(i, j, UP);
                if (j == (game.getHeight() - 1))
                    addWall(i, j, DOWN);
                if (!game.get(j, i).contains(BasicMaze.Direction.LEFT))
                    addWall(i, j, BasicMaze.Direction.LEFT);
                if (i == (game.getWidth() - 1))
                    addWall(i, j, RIGHT);
            }

This actually cleans things up a bit in our main draw loop. We'll update drawWall method to return a Rectangle:

    public Rectangle drawWall(Rectangle2D r) {
        Rectangle w = new Rectangle(r.getMinX(), r.getMinY(), r.getWidth(), r.getHeight());
        w.setFill(Color.web("0x353535", 1.0));
        board.piecePane.getChildren().add(w);
        return w;
    }

And the new addWall will incorporate that method:

    public void addWall(int i, int j, Direction d) {
        var isHorz = (d == UP || d == DOWN);
        var isPlus = (d == RIGHT || d == DOWN);
        var r = drawWall(board.getWallDim(new Coord(i, j), isHorz, isPlus));
        walls.put(new CoordPair(
                new Coord(i, j),
                new Coord(i + (d == RIGHT ? + 1 : d == LEFT ? - 1 : 0),
                        j + (d == DOWN ? + 1 : d == UP ? - 1 : 0))), r);
    }

So, if we're at (0,0) and we can't go right, then we have a wall with a coordinate of (0,0)->(0,1). BasicMaze is prepared to notify us when a wall is removed after the call to connect, you may recall, so we just need to hook up a removeWall to that event. In the MazeModelView constructor:

game.addUpdater(this::removeWall);

Make sure you remove the addConsumer that points to draw or you'll just slow things down even more!

removeWall is simple enough:

    public void removeWall(CoordPair c) {
        Platform.runLater( ()-> board.piecePane.getChildren().remove(walls.get(c)));
    }

The resultant effect is blazingly fast compared to redrawing the entire screen every change. I found that, where before, a sleep of 10 milliseconds was about as low as I could go without getting a choppy result, I could lower the sleep to one nanosecond and see the maze play out.

maze2.gif

Two things:

1) It's still about an order of magnitude slower than NOT updating the screen at all. 2) One nanosecond is a very rough approximation. Below 1/4 of a millisecond, it became hard for me to detect any change at all.

OK, we'll gamify this next time, by adding a character who can move around the maze. This will help us with Dungeon Slippers.

Code is here.