FX My Life 2.4: Coloring Within The Lines

·

17 min read

Last time we left with a partly functional, but messy, grid control. First was the setBackgrounds with its duplicated code for creating background fills:

public void setBackgrounds() {
        int rows = _rowCount.get();
        int cols = _colCount.get();
        BackgroundFill[] fills = new BackgroundFill[rows * cols + 1];
        double gridWidth = widthProperty().doubleValue();
        double gridHeight = heightProperty().doubleValue();
        Color[] check = {Color.RED, Color.BLUE};
        for (int i = 0; i < cols; i++)
            for (int j = 0; j < rows; j++) {
                Color color = check[(((i % 2) + j) % 2)];
                fills[j + (i * rows)] = new BackgroundFill(color, CornerRadii.EMPTY,
                        new Insets(j * rowHeight(),
                                gridWidth - (colWidth() + colWidth() * i),
                                gridHeight - (rowHeight() + rowHeight() * j),
                                i * colWidth()));
            }
        if (_hoverCoord != null) {
            fills [rows * cols] = new BackgroundFill(Color.YELLOW, CornerRadii.EMPTY,
                    new Insets(_hoverCoord.get().y * rowHeight(),
                    gridWidth - (colWidth() + colWidth() * _hoverCoord.get().x),
                    gridHeight - (rowHeight() + rowHeight() * _hoverCoord.get().y),
                    _hoverCoord.get().x * colWidth()));
        }
        this.setBackground(new Background(fills));
    }

Let's refactor out the BackgroundFill:

    public BackgroundFill FillForCoord(int x, int y, Color color) {
        return new BackgroundFill(color, CornerRadii.EMPTY,
                new Insets(y * rowHeight(),
                        widthProperty().doubleValue() - (colWidth() + colWidth() * x),
                        heightProperty().doubleValue() - (rowHeight() + rowHeight() * y),
                        x * colWidth()));
    }

    public void setBackgrounds() {
        int rows = _rowCount.get();
        int cols = _colCount.get();
        BackgroundFill[] fills = new BackgroundFill[rows * cols + 1];
        Color[] check = {Color.RED, Color.BLUE};
        for (int i = 0; i < cols; i++)
            for (int j = 0; j < rows; j++) {
                Color color = check[(((i % 2) + j) % 2)];
                fills[j + (i * rows)] = FillForCoord(i, j, color);
            }
        if (_hoverCoord != null) {
            fills[rows * cols] = FillForCoord(_hoverCoord.get().x, _hoverCoord.get().y, Color.YELLOW);
        }
        this.setBackground(new Background(fills));
    }

That's better. We could also not create the grid for the _hoverCoord (where the mouse is, recall) but that's an optimization we don't currently need.

Next, our handlings for when the mouse enters and exits aren't quite right. We don't color the highlighted square until the user moves the mouse to the second cell, and we don't stop coloring the square when the mouse exits. That's easily fixed:

this.setOnMouseEntered(event -> {
            _hoverCoord = new SimpleObjectProperty<Coord>(getCoord(event.getX(), event.getX()));
            _hoverCoord.addListener(redraw);
            setBackgrounds();
        });

        this.setOnMouseExited(event -> {
            _hoverCoord = null;
            setBackgrounds();
        });

The last thing we were looking at fixing was the Coord object being recreated rather than just changed. Let's put a println in SetBackgrounds to illustrate:

    public void setBackgrounds() {
        System.out.println(_hoverCoord);
        int rows = _rowCount.get();
. . .

Now, when we move the mouse, we see this:

image.png

New coordinate objects, as you see. Since our getCoord function creates a coordinate, if we just set the coordinate to the value returned, we're still creating a bunch of objects, we're just throwing them away super fast.

This smacks of the root of all evil (premature optimization) but let's just try this:

public Coord setCoord(Coord c, double x, double y) {
        return new Coord(boundCol(x / colWidth()), boundRow(y / rowHeight()));
    }

I put this code next to getCoord because of the similarities, and the possibility we might want to refactor those two methods.

    public void setCoord(Coord c, double x, double y) {
        c.x = boundCol(x / colWidth());
        c.y = boundRow(y / rowHeight());
    }

Now this "works" in the sense of changing the passed in coordinate, but not in the important sense of triggering the invalidation and coloring the right cell.

First, I notice there's an error in setOnMouseEntered:

 _hoverCoord = new SimpleObjectProperty<Coord>(getCoord(event.getX(), event.getX()));

Memo to self: Beware of code completion putting the same thing in twice. (Second time it's happened that I did "X" twice instead of "X" and "Y".)

Anyway, we can just call the setBackgrounds directly, like we're doing with the enter and exit routines:

        this.setOnMouseMoved(event -> {
           this.setCoord(_hoverCoord.get(), event.getX(), event.getY());
           this.setBackgrounds();
        });

It would be interesting to have the listener be aware of object changes. Otherwise, if we want to prevent unnecessary redraws, we need to manually check the X and Y changes. But, heh, we don't really have a way to do that without creating another Coord object. So let's make it possible to get the grid X or Y value of a mouse X or Y value:

public int getAxisVal(double w, boolean isX) {
        return isX ? boundCol(w / colWidth()) : boundRow(w / rowHeight());
    }

Which we might as well use in our getCoord and setCoord:

    public Coord getCoord(double x, double y) {
        return new Coord(getAxisVal(x, true), getAxisVal(y, false));
    }

    public void setCoord(Coord c, double x, double y) {
        c.x = getAxisVal(x, true);
        c.y = getAxisVal(y, false);
    }

Now we can check in our setOnMouseMoved:

        this.setOnMouseMoved(event -> {
            int x = getAxisVal(event.getX(), true);
            int y = getAxisVal(event.getY(), false);
            Coord c = _hoverCoord.get();
            if (c.x != x || c.y != y) {
                this.setCoord(_hoverCoord.get(), event.getX(), event.getY());
                this.setBackgrounds();
            }
        });

This gets the job done. Although I'm not convinced we needed to do it at all, I feel like with a component that we will use a lot we can err on the side of conserving resources.

The point of creating the _hoverCoord property, however, was that I thought maybe we could bind it to the mouse X and Y via a helper routine. In other words, you're allowed with properties to bind them like this (sorta):

someProperty.bind(Bindings.createIntegerBinding(
   ()->someFunc(someObject.getProperty(),
   someObject.propertyDependency
. . .

So, ideally, we'd want to do something like:

hoverCoord.bind(Bindings.createObjectBinding(
  ()->Grid.getCoord(Mouse.getPosition(),
   Mouse.position
. . .

Or something like that. But our only way of interacting with the mouse is waiting for a mouse event. After exploring some interesting options in mouse control and binding properties, I think we'll just switch hoverCoord back to being just a coordinate:

 private Coord hoverCoord = null;

And deal with it directly:

        this.setOnMouseMoved(event -> {
            int x = getAxisVal(event.getX(), true);
            int y = getAxisVal(event.getY(), false);
            if (hoverCoord.x != x || hoverCoord.y != y) {
                this.setCoord(hoverCoord, event.getX(), event.getY());
                this.setBackgrounds();
            }
        });

Tests Re-Test

I'm more concerned about what happened to our tests. There were serious issues with our code we discovered last time, some of which we could attribute to not extensive enough testing but others were just plain wrong.

Well, we haven't been running the tests regularly enough. Let's run them now.

image.png

Wow, they all failed.

This is why we always run tests (and even use auto-running tests) whenever we're changing code (with the possible exception of when we're just experimenting and don't plan to keep any of the changes).

Unsurprisingly, the problem occurred when we switched colCount from an int to a SimpleIntegerProperty. And, debugging, I see an interesting thing: When we're testing for the value of the property, it's zero (when it should be 3) even though the bean property has a value of 3.

There's something about the timing of that I don't understand, but generally speaking when you use a bean property, you want to set its value using the setWhatever routine. Now, we're setting it when we're creating the property, you may recall.

    _colCount = new SimpleIntegerProperty(2, "colCount");
    _rowCount = new SimpleIntegerProperty(rows, "rowCount");

Well, what if we created them in the definition area:

   private final SimpleIntegerProperty _colCount = new SimpleIntegerProperty(0, "colCount");
    private final SimpleIntegerProperty _rowCount = new SimpleIntegerProperty(0, "rowCount");

And then set them in the constructor?

        setColCount(columns);
        setRowCount(rows);

Sure enough, the tests go back to working. I've put a pin in that, mentally, because I suspect we'll be coming back to various bean/property issues as we move forward, and I don't think I really understand what's going on. (Although maybe it is just as simple as "gotta use the set method".)

While we're at it, let's change the Grid test so that we have a different number of rows compared to columns:

g = new Grid(3, 5);

This way, if we mix up rows and columns (kaff) in some future test, it'll be easier to see.

Well, that wasn't so bad. Let's get back to adding features!

##Over The Borderline We probably don't need an outer border, but it's not a bad place to start, so let's do that.

Two approaches suggest themselves as far as drawing lines go: We can draw directly to the canvas or we can use the line and/or rectangle shapes. I'm going to opt for the latter because it seems like we're still too green to be messing with JavaFX primitives, and we're already biting off a bit by making our own component, so maybe we shouldn't get to distracted from that.

And, now that I'm thinking about it, I wonder if it would be useful not to have an outer border per se but to have each box in the grid have its own borders. Let me elaborate with random images from a web search.

Suppose we have a grid, and we want a border around it. It might look like this:

image.png

Pretty easy to do. We could have thickness and color and style...I mean, if it's a shape, we can do anything to it we can do to a shape as far as drawing it. So, we can do that, and we can follow up by drawing horizontal lines:

image.png

And then vertical lines to create the grid:

image.png

The advantages of this are that it's simple...and it's simple. And it might even be adequate in many places. Like, we can manage tic-tac-toe with this: It's no outer border combined with inner grid lines. Chess or checkers would work, because those would be an outer border and no inner grid lines.

While grid-wide lines work in these cases, they fall apart in just slightly more complex situations. Consider a maze:

image.png

Our outer border has to have holes in it or you can't escape the maze! This is relevant to Dungeon Slippers potentially, because we might want to set up something like this:

image.png

Going back to our "Slayaway Camp" inspiration, we can see a situation where, if the slasher kills the camper in the blue trunks, the camper with the pink ponytails runs shrieking in terror to the north. Since the north is open, she can escape and the game is lost.

Now, arguably, the same effect can be achieved by expanding the grid to the north one row, walling it off and making a gate that the player cannot pass through while other creatures can—but this doesn't really simplify things.

Note also that our map construction relies on barriers between grids, as seen with the short blue fences in the above picture. I feel like we may end up with our grid having different "modes" for different ways of handling these issues.

Well, let's not try to work it all out at once. Let's just start with a decorative border. Just guessing, we could do something like this in setBackgrounds:

Rectangle rect = new Rectangle(0, 0, this.widthProperty().doubleValue(), this.heightProperty().doubleValue());
this.getChildren().add(rect);

Seems like we'd need a width or a color or something. What does this do?

image.png

Ah. By default, the shape is filled. Well, okay, let's have it NOT be filled.

        Rectangle rect = new Rectangle(0, 0, this.widthProperty().doubleValue(), this.heightProperty().doubleValue());
        rect.setFill(null);
        this.getChildren().add(rect);

Heh. Now it's invisible. We just get our checkerboard pattern. Shapes have outlines, determined their "stroke", and fills. If we're not going to use a fill, we'd best use a stroke or we'll have nothing at all for our efforts.

        Rectangle rect = new Rectangle(0, 0, this.widthProperty().doubleValue(), this.heightProperty().doubleValue());
        rect.setFill(null);
        rect.setStroke(Color.BLACK);
        rect.setStrokeWidth(5);
        this.getChildren().add(rect);

The effect that this creates is...interesting. It may work at first, but then the frame grows thicker and thicker and crowds the rest of the grid out!

image.png

Well, we're creating a new border every time. That's problematic. What if we create a border member for the class?

 private Rectangle border = null;

And we'll just reset it every time:

        this.getChildren().remove(border);
        border = new Rectangle(0, 0, this.widthProperty().doubleValue(), this.heightProperty().doubleValue());
        border.setFill(null);
        border.setStroke(Color.BLACK);
        border.setStrokeWidth(5);
        this.getChildren().add(border);

Ha! Now the grid gets bigger and bigger every time!

image.png

What I'd guess is happening, here, is that putting in the rectangle is expanding the dimensions of the Grid itself, and this triggers a redraw which expands the dimensions of the Grid itself which triggers a redraw...you get the idea.

We can prove this by changing our Rectangle creation to:

border = new Rectangle(0, 0, 10, 10);

Oh.

image.png

Well, this proves our suspicion since it doesn't cause any weird ever-expanding whatevers. But why is the square in the center?

Well, for once, the docs explain something!

The stackpane will attempt to resize each child to fill its content area. If the child could not be sized to fill the stackpane (either because it was not resizable or its max size prevented it) then it will be aligned within the area using the alignment property, which defaults to Pos.CENTER.

If we look back at our "inspiration" grid, we recall that Mr. Mauky used layers to draw things. We always figured we'd end up doing the same, and now's the time!

    private final Pane borderPane = new Pane();
    private Rectangle border = null;

We've made a Pane to put border in. Now we'll set that up (in the constructor). Our inspirational grid has something like this:

        borderPane.setMouseTransparent(true);

And I'm on the fence about adding it. On the one hand, if we put it in, it keeps us from having that frustrating moment where our mouse clicks are being swallowed by an invisible, non-interactive layer. But that should never happen, and it encourages that frustrating moment where our layer doesn't respond to mouse clicks and we don't know why (because we forgot we set mouse transparency on).

We'll leave it out for now, but keep it in mind in case we have mysterious event swallowing later on. So let's just do this:

        borderPane.maxWidthProperty().bind(widthProperty());
        borderPane.maxHeightProperty().bind(heightProperty());
        this.getChildren().add(borderPane);

You can't bind the width and height properties directly: They're read only. As nervous as it makes me to use maxWidth and maxHeight—I don't want these to be the maximum dimensions, I want them to be the only possible dimensions—we'll start here and see how it works out.

Now, in setBackgrouds:

        borderPane.getChildren().remove(border);
        border = new Rectangle(0, 0, this.widthProperty().doubleValue(), this.heightProperty().doubleValue());
        border.setFill(null);
        border.setStroke(Color.BLACK);
        border.setStrokeWidth(5);
        borderPane.getChildren().add(border);

Unspectacular, perhaps:

image.png

But it works! And the border tracks with the resized window.

Also, I've decided to start committing more often so the code is easier to follow along with. It's just common sense, really. Our progress so far .

The Inner Lines

Lets get rid of the border member and just create the border in the setBackgrounds where we set it up, and just clear everything from the borderPane whenever we're setting up the borders and grid lines:

        borderPane.getChildren().clear();
        Rectangle border = new Rectangle(0, 0, this.widthProperty().doubleValue(), this.heightProperty().doubleValue());
        border.setFill(null);
        border.setStroke(Color.BLACK);
        border.setStrokeWidth(5);
        borderPane.getChildren().add(border);

Now we'll add in our grid lines, right after:

        for (int i = 1; i < cols; i++) {
            Line line = new Line(i * colWidth() - 1, 0, i * colWidth() - 1, this.heightProperty().doubleValue());
            line.setStroke(Color.BLACK);
            line.setStrokeWidth(2);
            borderPane.getChildren().add(line);
            }

We have to use even numbers for grid lines in order to keep the amount of the grid line equal on either side of the line. This looks pretty good but slightly off.

image.png

Are we getting a bleed into adjacent cells?

image.png

I think we are but we're not going to worry about it just yet, because we're going to be tweaking a lot of things that have to do with how the cells and gridlines interact.

Clean-Up Time

The next thing we should tackle is state. We're at the point where we could use this grid but we do need to figure out how it's going to interact with the game's state. That's worthy of its own entry, so let's just wrap this up by making properties out of the grid attributes:

  1. Pattern color
  2. Border Pane (size and color)
  3. Grid Lines (size and color)

We can obviously do a lot more with these, and we might (e.g.) expose the stroke properties directly at some point so that Grid users can have as much control over the appearance of these elements as JavaFX allows. But this is a reasonable start.

Pattern Color

Pattern color is something we just created locally and called check, so let's give that a more meaningful name and move it to the class description:

    private final Color[] colorPattern = {Color.RED, Color.BLUE};

We can change the old check to colorPattern and everything works just as before, except we should use colorPattern.length instead of 2. Now, how do we make this a property?

Well, FX has a bunch of observable collections:

  private final ObservableList<Color> colorPattern;
  colorPattern = FXCollections.observableArrayList(Color.RED, Color.BLUE);
  colorPattern.addListener(redraw);

OK, once again, this works well:

public void setBackgrounds() {
        int rows = _rowCount.get();
        int cols = _colCount.get();
        **int numColors = colorPattern.size();**
        BackgroundFill[] fills = new BackgroundFill[rows * cols + 1];
        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)] = FillForCoord(i, j, color);
            }

Now we should be able to scramble colors as well as grid dimensions. Let's create a random array of colors:

        System.out.print("Colors: ");
        for(int i = 0; i < Math.random()*10; i++) {
            Color c = new Color(Math.random(), Math.random(), Math.random(), 1);
            System.out.print(" - "+c.toString());
            cp.add(c);
        }
        System.out.printf(" => %d\n", cp.size() );

        grid1.setColorPattern(cp);

We'll need a setColorPattern routine for grid:

public final void setColorPattern(ArrayList<Color> cp) {
        colorPattern.clear();
        colorPattern.addAll(cp);
    };

And now when we click on the Grid, it will not just change the row and column count, it will change the colors. Neat!

Hmmm. Wait, we did the horizontal lines, but forgot to do the vertical lines, so let's add that code in setBackgrounds:

        for (int j = 1; j < rows; j++) {
            Line line = new Line(0, j * rowHeight() - 1, this.widthProperty().doubleValue(), j * rowHeight() - 1);
            line.setStroke(Color.BLACK);
            line.setStrokeWidth(2);
            borderPane.getChildren().add(line);
        }

That actually looks pretty nice:

image.png

But we do get a divide-by-zero error message. The culprit is, I think, here:

public final void setColorPattern(ArrayList<Color> cp) {
        colorPattern.clear();
        colorPattern.addAll(cp);
    };

Because of this in setBackgrounds:

        int numColors = colorPattern.size();     //here
        BackgroundFill[] fills = new BackgroundFill[rows * cols + 1];
        for (int i = 0; i < cols; i++)
            for (int j = 0; j < rows; j++) {
                Color color = colorPattern.get(((i % numColors) + j) % numColors); //here

When we clear colorPattern, it triggers the redraw, but we assume there will be at least one color. We could get fancy about it, but let's just do this:

        int numColors = colorPattern.size();
        if(numColors == 0) return; //bail out!
        BackgroundFill[] fills = new BackgroundFill[rows * cols + 1];

That fixes it. Now, borderWidth:

private final SimpleIntegerProperty _borderThickness = new SimpleIntegerProperty(5);

We'll set up the listener in the constructor:

        _borderThickness.addListener(redraw);

Which we'll use in setBackgrounds:

border.setStrokeWidth(_borderThickness.get());

And we'll need to be able to set the width:

public final void setBorderThickness(int bw) {
        _borderThickness.set(bw);
    }

And now to test:

        int bw = (int)Math.round(Math.random()*20);
        System.out.printf("Border width to =>%d\n", bw);
        grid1.setBorderThickness(bw);

This works but it raises the obvious point that our border covers the grid squares rather than fits around them. This is a serious issue for a game with small squares. Like, if the squares are 10x10 and the border is 20 pixels, we're going to cover the outside two columns and rows!

However, right now we're looking at cells that are going to be more like 50 or 100 pixels, so let's not mess with it just now. Also, something interesting to keep in mind shows up when we property-ize the border color (you should be able to figure out where the code goes by now):

private final SimpleObjectProperty<Color> _borderColor = new SimpleObjectProperty<>(Color.BLACK);
. . .
  _borderColor.addListener(redraw);
. . .
 border.setStroke(_borderColor.get());
. . .
    public final void setBorderColor(Color c) {
        _borderColor.set(c);
    }
. . .

And randomize it in the controller:

 grid1.setBorderColor(new Color(Math.random(), Math.random(), Math.random(), 1));

And we get something like this:

image.png

This illustrates that we're laying down the background squares first, the border can cover that, but then the grid lines go over that.

So, for practical purposes, there's just a little bleed by the border, and we don't need to care about it for right now, I don't think. OK, on to grid lines:

private final SimpleIntegerProperty _gridLineThickness = new SimpleIntegerProperty(2);
    private final SimpleObjectProperty<Color> _gridLineColor = new SimpleObjectProperty<>(Color.BLACK);
. . .
        _gridLineThickness.addListener(redraw);
        _gridLineColor.addListener(redraw);
. . .
  for (int i = 1; i < cols; i++) {
            Line line = new Line(i * colWidth() - 1, 0, i * colWidth() - 1, this.heightProperty().doubleValue());
            line.setStroke(_gridLineColor.get());
            line.setStrokeWidth(_gridLineThickness.get());
            borderPane.getChildren().add(line);
            }

        for (int j = 1; j < rows; j++) {
            Line line = new Line(0, j * rowHeight() - 1, this.widthProperty().doubleValue(), j * rowHeight() - 1);
            line.setStroke(_gridLineColor.get());
            line.setStrokeWidth(_gridLineThickness.get());
            borderPane.getChildren().add(line);
        }
. . .

    public final void setGridLineColor(Color c) {
        _gridLineColor.set(c);
    }
    public final void setGridLineThickness(int glw) {
        _gridLineThickness.set(glw);
    }

And then we can randomize it, though we have to limit the size or our grid lines will cover the cells entirely:

        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));

OK, now we just have the hover color. This should be pretty automatic by now:

private final SimpleObjectProperty<Color> _hoverColor = new SimpleObjectProperty<>(Color.YELLOW);
. . .
_hoverColor.addListener(redraw);
. . .
if (hoverCoord != null) {
            fills[rows * cols] = FillForCoord(hoverCoord.x, hoverCoord.y, _hoverColor.get());
        }
. . .
public final void setHoverColor(Color c) {
        _hoverColor.set(c);
    }

OK! Well, this all works, although certainly some combinations don't work very well, we won't be randomizing from a set of all possibilities. Let's leave it for now and next time we'll tackle state.