FX My Life 2.5b: "/" for Victory

·

10 min read

We left off last time with our Tic-Tac-Toe game placing pieces correctly but that's about it. The underlying game prevents pieces from being played after the game has ended and of the original draw routine in TttController, we have only these lines of code left to implement:

        drawVictorySlash();

        p1Score.setText(String.valueOf(game.playerOneWins));
        p2Score.setText(String.valueOf(game.playerTwoWins));
        tieScore.setText(String.valueOf(game.ties));
        if (game.playerOneIsX) {
            XoverO.setRotate(0);
        } else {
            XoverO.setRotate(180);
        }

This does three things:

  1. Draws the victory slash.
  2. Sets the score text.
  3. Adjusts the playerOneIsX control.

#2 and #3 shouldn't be in the grid model-view, so we'll have to figure out how to do them back in the TttController. Maybe a reduced draw method?

The victory slash, on the hand, seems perfectly suited for the grid. Let's look at that code:

public void drawVictorySlash() {
        if (!game.winner.equals("")) {
            if (game.winner.equals("-")) {
                message.setText("Game over! " + "It's a tie! Again!");
                return;
            }
            message.setText("Game over! " + game.winner + " wins!");
            var bw = board.getWidth();
            var bh = board.getHeight();
            var cw = bw / 3;
            var ch = bh / 3;
            var wxs = 0.0;
            var wys = 0.0;
            var wxe = bw;
            var wye = bh;

            innerGroup.getChildren().add(line);
            line.getStyleClass().add("line");
            if (game.vxd == 1 && game.vyd == 1) {
                //don't actually have to do anything since these are the defaults, but lets leave the case in here
            } else if (game.vxd == -1 && game.vyd == 1) {
                wys = bh;
                wye = 0;
            } else if (game.vxd == 1) {
                wys = (game.vy * ch) + (ch / 2);
                wye = wys;
            } else if (game.vyd == 1) {
                wxs = (game.vx * cw) + (cw / 2);
                wxe = wxs;
            }
            line.setStartX(wxs);
            line.setStartY(wys);
            line.setEndX(wxe);
            line.setEndY(wye);
        }
    }

The set text should remain in the TttController as well. The code from var bw = down to line.setEndY is what we're interested in. And we're interested in it for a number of reasons:

  1. We need to be aware of game-state changes—which might not always originate from the grid!
  2. We might want to have a decoration layer in the grid—a place where we put "transitory" effects that aren't technically part of game sate.
  3. We might also see that there are a few more convenience functions that could be useful.

Starting with #1, we have up till now not had to change the TicTacToe game model at all, which was our intent. We added a convenience method:

public boolean canAccept(String s, int x, int y) {
        return (state[y][x]==null);
    }

But we could've just used the existing get

    public String get(int x, int y) {
        return state[y][x];
    }

My original idea was to make canAccept check the s value to make sure it was the right player's turn but that didn't turn out to be necessary.

So we haven't had to do anything substantial, and I think that has to change, in the least disruptive way possible: The TicTacToe model should be observable. That way the view-model can react to changes and the TicTacToe model still doesn't have to know that it exists.

Qui custodiet ipsos custodes?

Now, when I say "observable", I don't necessarily mean descended from JavaFX's Observable class. I think there's probably nothing horrible about descending from a JavaFX class in our game model, but ideally the model shouldn't know anything about the interface library.

So, what do we want, really? We want changes to the game state to fire off methods in other objects This gives us some perspective on the getter/setter discussion we've had a few times already. I'm against, as a general rule, doing anything automatically. Boilerplate is an enemy—maybe even the enemy, because it leads to people turning the brain off.

But here we can see the value of using methods to access features: We can set up dependencies based on those methods which we can't do with just a dumb field. For state, we actually only update it through the addPiece method—well, and the reset method.

But let's set up some Consumers, a plain old Java class that allows us to use plain old lambdas. We'll need a list of consumers:

private final List<Consumer<TicTacToe>> consumers = new ArrayList<>();

And we'll put in the necessary add, remove and apply methods:

    public void addConsumer(Consumer<TicTacToe> l) {
        consumers.add(l);
    }

    public void removeConsumer(Consumer<TicTacToe> l) {
        consumers.remove(l);
    }

    public void alertConsumers() {
        consumers.forEach(c -> c.accept(this));
    }

This is probably the most ham-handed approach to notifications possible: We just let people add themselves to our consumer list and then just call their function, like, "Hey, something changed about me. Guess what!"

But that's fine. For a more complex game, we probably would want something more rich (like actual Observable properties on features) so the consumers could know what precisely was changed, from what and to what. And if we had a situation where one component (like Grid) reflects one complex and expensive aspect of the game state (drawing the whole board) and other components (like the text messages saying who won, etc.) reflect simpler things that can be changed rapidly and cheaply, we would almost certainly end up caring about it.

We can see the changes we made (which won't actually change behavior if we did everything properly) by making changes in our view-model:

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

        game.addConsumer((game) -> this.draw()); //add this line

Now that we can consume changes, let's take on the victory slash.

Slash It. Slash It Good.

Let's revisit TttController's method drawVictorySlash. It starts by setting message texts (poorly named function, eh, what?):

    public void drawVictorySlash() {
        if (!game.winner.equals("")) {
            if (game.winner.equals("-")) {
                message.setText("Game over! " + "It's a tie! Again!");
                return;
            }
            message.setText("Game over! " + game.winner + " wins!");

Of this, the only thing we really want is to exit early if there is no winner:

        if (!game.winner.equals("")) {
            if (game.winner.equals("-")) {
                return;
            }

That's a little clunky. Let's actually move that test into draw:

                    board.piecePane.getChildren().addAll(xo);
                }
        }
        if (Objects.equals(game.winner, "X") || Objects.equals(game.winner, "O"))
            drawVictorySlash();
    }

Next up, we try to figure out where to draw the slash:

        var bw = board.getWidth();
        var bh = board.getHeight();
        var cw = bw / 3;
        var ch = bh / 3;
        var wxs = 0.0;
        var wys = 0.0;
        var wxe = bw;
        var wye = bh;

We don't need to calculated cell width or height any more—and shouldn't really. Let's just use the grid calls:

        var wxs = 0.0;
        var wys = 0.0;
        var wxe = board.getWidth();
        var wye = board.getHeight();

As you'll recall, wxs and wys are the starting x and y coordinates, while wxe and wye are the ending. We start with a diagonal slash, left to right, and then alter the coordinates depending.

The code shouldn't have to change at all, but it does—and if you've been following along, you may be able to guess why. Originally, it was:

innerGroup.getChildren().add(line);
            line.getStyleClass().add("line");
            if (game.vxd == 1 && game.vyd == 1) {
                //don't actually have to do anything since these are the defaults, but lets leave the case in here
            } else if (game.vxd == -1 && game.vyd == 1) {
                wys = bh;
                wye = 0;
            } else if (game.vxd == 1) {
                wys = (game.vy * ch) + (ch / 2);
                wye = wys;
            } else if (game.vyd == 1) {
                wxs = (game.vx * cw) + (cw / 2);
                wxe = wxs;
            }
            line.setStartX(wxs);
            line.setStartY(wys);
            line.setEndX(wxe);
            line.setEndY(wye);

We'll add the line to the board, rather than innerGroup. We won't bother with an "effects" layer like we discussed earlier, because we don't need it here. Maybe later.

But the real change is in the if blocks:

        Line line = new Line();
        board.piecePane.getChildren().add(line);
        line.getStyleClass().add("line");
        if (game.vxd == 1 && game.vyd == 1) {
            //don't actually have to do anything since these are the defaults, but lets leave the case in here
        } else if (game.vxd == -1 && game.vyd == 1) {
            wys = board.getHeight();
            wye = 0;
        } else if (game.vyd == 1) {
            wys = (game.vx * board.cellWidth()) + (board.cellWidth() / 2);
            wye = wys;
        } else if (game.vxd == 1) {
            wxs = (game.vy * board.cellHeight()) + (board.cellHeight() / 2);
            wxe = wxs;
        }
        line.setStartX(wxs);
        line.setStartY(wys);
        line.setEndX(wxe);
        line.setEndY(wye);

In the original game, we were storing the state in columns rather than rows. It's generally easier to look at the data when it's stored in rows:That way, if the board looks like:

image.png

Then the printout of the state looks like:

[[X, O, X], 
[null, X, null], 
[O, X, O]]

But our game check code still thinks it's being stored the other way to. It's worth fixing, probably, at some point, but it works and we have bigger fish to fry so we can get out of Tic-Tac-Toe land.

Before we wrap this section up, let's change the victory color, because red doesn't work well with the red/blue backdrop. That's in the CSS, you may recall:

.line {
    -fx-stroke: green;
    -fx-stroke-width: 8;
    -fx-stroke-line-cap: round;
}

It ain't pretty but it'll do.

image.png

Reconnecting The Controller

Now our original controller, TttController is smaller and more focused because all the grid stuff has been put into the view model. But this has broken our buttons and some of our messaging.

The "Back" button still works, so we got that going for us (which is nice). But we've basically disconnected our redrawing function—well, we actually deleted it completely. So, I think we want to copy this from the view model:

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

But we don't have a this.draw(). Well, let's investigate what each button needs.

The "New" button does actually reset the board but it's not being reflected on-screen. Hmm. Looks like this line:

board.piecePane.getChildren().removeAll();

which served us well in TttController is no longer doing the trick. That's because removeAll requires a list of items to be removed in order to actually do anything. Back when this was in TttController, we were removing specific objects with a regular remove. What we need now is:

board.piecePane.getChildren().clear();

And now the New button works, clearing the board.

Next up, a victory (or a tie) is no longer updating the score or messages. Before, we had this in DrawVictorySlash:

 if (!game.winner.equals("")) {
            if (game.winner.equals("-")) {
                message.setText("Game over! " + "It's a tie! Again!");
                return;
            }
            message.setText("Game over! " + game.winner + " wins!");

So let's stuff that into a new draw method for the controller and add this to the initialization:

        tttvm = new TttViewModel(board, game, this);
        game.addConsumer((game) -> this.draw()); //<--this line is new

        NodeController.me.addHandler(outerGroup, (e) -> {

OK, now the winning message works. We also had this in TttController's old draw method:

   p1Score.setText(String.valueOf(game.playerOneWins));
        p2Score.setText(String.valueOf(game.playerTwoWins));
        tieScore.setText(String.valueOf(game.ties));
        if (game.playerOneIsX) {
            XoverO.setRotate(0);
        } else {
            XoverO.setRotate(180);
        }

So let's put that back into the new method. Oh, and when we took out the original draw method, we took out all the calls to it, too, most of which weren't needed but this one still was:

 public void togglePlayerX() {
        game.togglePlayerX();
        draw(); //<--make sure that's there
    }

Now things work pretty well and the "Rando" and "MinMax" auto-players also work. But if we try to save or load, we're going to get a NotSerializableError and I can only assume the culprit is this:

 private final List<Consumer<TicTacToe>> consumers = new ArrayList<>();

Which makes sense. The two consumers are the model view and the original controller, and neither of those is serializable. We can fix that by marking this field as transient:

private final transient List<Consumer<TicTacToe>> consumers = new ArrayList<>();

Loading it won't work, however, because the grid needs that consumer list populated. We can probably do better than this, but it should work for now:

                                game = (TicTacToe) in.readObject();
                                in.close();
                                fileIn.close();
                                game.addConsumer((game) -> this.draw());
                                tttvm = new TttViewModel(board, game, this);
                                game.alertConsumers();

But it doesn't work! Because the consumers list in game is null when being loaded from a stream. So we need to check that before trying to do things with the list. This seems to be the least disruptive approach:

    public void addConsumer(Consumer<TicTacToe> l) {
        if (consumers == null) consumers = new ArrayList<>(); //<--add this
        consumers.add(l);
    }

You might consider taking this out:

private transient List<Consumer<TicTacToe>> consumers ~~= new ArrayList<>();~~

But that will cause problems here:

    public void alertConsumers() {
        consumers.forEach(c -> c.accept(this));
    }

So you add two tests for null or you make sure that consumers is always an instantiated ArrayList, even if you're just creating it for the brief moment before it's populated.

We've got one more oddity: When we load the game, the player names don't get updated. I think this must have just been something overlooked previously because I don't see how it was set.

I'm fine with just setting it in the load code, like so:

                                player1.setText(game.playerOneName);
                                player2.setText(game.playerTwoName);

(after the game is instantiated, obviously).

Now, we could have done the scores, the player names and the messages via binding, but let's escape the Tic-Tac-Toe ghetto for a while and go back to more fun things.

We've seen the value of the view-model approach, and we can use it in the future effectively.

The latest code is available here .