FX My Life 2.9: Wrap-Up

·

11 min read

OK, before starting the next chapter and a new game, let's take one last pass through the Maze game, focusing on three things:

  1. Functionality all working
  2. Any obvious refactoring
  3. Any functionality we'd like to add. The functionality we haven't checked in a while is the loading and saving. So let's check that. Generate a maze and click the save button, enter a file name, and press enter and...
    java.io.NotSerializableException: fxgames.Coord
    
    Well, yes, I suppose we never did make Coord serializable. Easily fixed:
import java.io.Serializable;

public class Coord implements Serializable {

There, now we can save. What happens when we load? Exit the program completely and fire it up again so that none of the old state is left over, then load the game you just saved.

Looks great! Except for the no tokens thing. Hmm. So, if you think back to tic-tac-toe, you may recall we had to set up the consumers for the game after reloading it, since those are not serializable. We have to do that and a little more here:

                                fileIn.close();
                                mvm = new MazeViewModel(grid, maze, this);
                                maze.addConsumer(mvm.drawFn); //add
                                mvm.resize(); /add
                                grid.requestFocus(); //add

So, we add in the drawFn (like we do in the removeWall call), resize() (for the token sizing, which we also do in the removeWall call), and then grid.requestFocus so that the game can capture the key strokes. This cries out for some sort of refactoring, no?

But before we do that, we have one other issue, which is that if we finish the maze (whether by escaping or being eaten) and then load a maze, the previous state persists. What's up with that?

Well, it's not too hard to figure out: We create a new maze on loading, but we never really got rid of the old one, nor did we get rid of the old MazeViewModel, which means it's still running underneath it all!

When we created a new maze, we actually just reset the old maze object! We only ever allowed one each of the BasicMaze and the MazeViewModel. Well, we can use that approach here if we allow the BasicMaze to be assigned values from another maze object:

    public void assign(BasicMaze source) {
        width = source.width;
        height = source.height;
        slots = source.slots;
        player = source.player;
        entrance = source.entrance;
        exit = source.exit;
        minotaur = source.minotaur;
        gameState = source.gameState;
        maze = source.maze;
    }

Now we can treat loading it almost exactly like we did a new maze:

                    case "load" -> {
                        file = getFilename(true);
                        if (file != null)
                            try {
                                FileInputStream fileIn = new FileInputStream(file);
                                ObjectInputStream in = new ObjectInputStream(fileIn);
                                var loaded_maze = (BasicMaze) in.readObject();
                                in.close();
                                fileIn.close();
                                maze.assign(loaded_maze);
                                if (mvm!=null) maze.removeConsumer(mvm.drawFn);
                                else mvm = new MazeViewModel(grid, maze, this);
                                maze.addConsumer(mvm.drawFn);
                                grid.requestFocus();
                                mvm.resize();
                            } catch (IOException | ClassNotFoundException i) {

And sure enough, our problems go away.

Now, as we've gone along and "gotten things done", not always in the cleanest or best way possible, we've gotten a sense of what's known in the industry as "technical debt". "Technical debt" is the concept that when you do things the quickest or easiest way possible, rather than the way the best lends itself to flexibility and maintenance, you end up needing to spend more time in the future when you need to revisit that code (whether for debugging or expansion or analsysis, etc.).

The "debt" aspect indicates the fact that the quicker/easier solutions don't just need immediate repair, over time they need increasing amounts of attention. They can get harder and harder to work with.

After a while you get a sense both of when you're creating technical debt and how much, and how you can avoid getting into debt in the future. For example, in this case, we had a slapdash approach to our maze game (sensibly, since we didn't even know we were going to make it up front) and our responsibilities are not cleanly divided and we lack a few clean points to "restart", "reload", "redraw".

Just as we learned to drive our game from the logic itself in tic-tac-toe, we'll carry on the lessons here in the next chapter.

Cleanup Time

Let's do some little odds-and-ends to clean things up a bit. For our pop-up message telling the user they've escaped, let's use a style:

messageText.getStyleClass().add("popup-message");

And set the style in the css:

.popup-message {
    -fx-text-fill: black;
    -fx-font-size: 18pt;
    -fx-font-weight: bold;
}

I feel like we're likely to come back to showMessage to generify that for other games.

We're not using the mouse cursor for anything so the highlighting of it is kind of distracting. What if we set the hoverColor to null?

        g.setColCount(m.getWidth());
        g.setRowCount(m.getHeight());
        g.setHoverColor(null); //added

This, somewhat surprisingly, works! The grid under the mouse no longer draws, and there are no errors caused by this. Well, good, we got one for free.

When we're handling key strokes, let's move the minotaur whether the player moves or not. (Mwah ha ha!)

                if (oldx != game.player.x || oldy != game.player.y) {
                    addTransition(new Transition(playerToken, new Coord(game.player.x, game.player.y)));
                }
                else {
                    game.moveMinotaur();
                    addTransition(new Transition(minotaurToken, new Coord(game.minotaur.x, game.minotaur.y)));
                }

We need to make moveMinotaur public:

public void moveMinotaur() {

This almost works. The minotaur will find the player but the victory/failure code is in with the "consume" block:

                    addTransition(new Transition(minotaurToken, new Coord(game.minotaur.x, game.minotaur.y)));
                }
                if (consume) {
                    keyEvent.consume();

                    if (game.gameState == BasicMaze.GameState.postGame) {

Well, the guy can't win unless he's moved, and he can't lose unless the minotaur's moved. What if we restructured the whole block like this:

                if (oldx != game.player.x || oldy != game.player.y)
                    addTransition(new Transition(playerToken, new Coord(game.player.x, game.player.y)));
                else game.moveMinotaur();
                addTransition(new Transition(minotaurToken, new Coord(game.minotaur.x, game.minotaur.y)));
                if (game.gameState == BasicMaze.GameState.postGame) {
                    messageWin.show(Main.me.stage);
                    showMessage("You have escaped!");
                } else if (game.gameState == BasicMaze.GameState.beenEaten) {
                    messageWin.show(Main.me.stage);
                    showMessage("You have been eaten!");
                }
                if (consume) keyEvent.consume();

Note that we only add a transition for the player when the player has moved, but we always add a transition for the minotaur. First of all, the minotaur always moves. Second of all, even if it didn't we could recall that transitioning to and from the same spot doesn't really cost much if anything.

We don't even need the if to add the user transition, here:

                if (oldx != game.player.x || oldy != game.player.y)
                    addTransition(new Transition(playerToken, new Coord(game.player.x, game.player.y)));
                else game.moveMinotaur();

But if the player hasn't moved, then we must explicitly move the minotaur. This signals a complexity we would want to eliminate were we to spend much more time here. (We could, for example, have change listeners for the player and the minotaur, which would pass in old and new coordinates.)

OK, this is a little too brutal, even for me, so let's filter out a few errant clicks. We could do something like:

                else {
                    var c = keyEvent.getCode();
                    if (c == KeyCode.ALT || c == KeyCode.SHIFT || c == KeyCode.COMMAND || c == KeyCode.CONTROL || c == KeyCode.WINDOWS) return;
                    game.moveMinotaur();
                }

Plain control keys won't move the minotaur in this case. But what are we really trying to do? We don't want to punish the player for, say, thinking he's switched windows and is typing an email. We do want to punish him for making a wrong turn, so let's only do that if he's typed in a code that we handle:

                if (oldx != game.player.x || oldy != game.player.y)
                    addTransition(new Transition(playerToken, new Coord(game.player.x, game.player.y)));
                else if (consume) game.moveMinotaur();

So, if he walks into a wall, the minotaur moves.

It's About Time

Let's make one more little enhancement. Let's make it so that the user can select a maze where the minotaur moves at a specific pace.

We'll add to the setup dialog a label and a spinner:

                        Label pDelay = new Label("Minotaur delay (secs.)\n (0 for turn-based)");
. . .
                        Spinner<Integer> delay = new Spinner<Integer>(0, 60, 0);
                        delay.editableProperty().set(true);
...
                        layout.add(pDelay, 1, 4);
                        layout.add(delay, 2, 4);
                        layout.add(button, 1,5);
                        layout.add(button2, 2, 5);
                        Scene scene = new Scene(layout, 300, 175);

Note that we have to move our buttons down to the next row and make the scene bigger to fit the new label and spinner.

Before we get to these, let's set up the minotaur movement in BasicMaze and MazeViewModel so that the turn-based version still works, but allows for the timing. In BasicMaze.movePlayer(), we'll take out the auto-move for the minotaur:

            if (player.equals(exit)) gameState = GameState.postGame;
            else moveMinotaur(); //remove this

Now in MazeViewModel, this code we just added no longer needs the else:

                if (oldx != game.player.x || oldy != game.player.y)
                    addTransition(new Transition(playerToken, new Coord(game.player.x, game.player.y)));
                else if (consume) game.moveMinotaur(); //remove the else

It can now just be:

addTransition(new Transition(playerToken, new Coord(game.player.x, game.player.y)));
if (consume) game.moveMinotaur();

We no longer need to keep track of whether the player moves. (You can, still, as an optimization, but I prefer simpler code until the need for speed arises.)

OK, now, let's add a field for the minotaur "lag":

    public int minoDelay = 0;

We could make this a property, and probably should if we ever anticipate the delay changing mid-game. For now, this will do. If we add a conditional to our moveMinotaur code:

                if (minoDelay==0 && consume) game.moveMinotaur();

Once again, we'll see that all is well in the turn-based version of the game. To go real-time, we need an else:

                if (minoDelay==0 && consume) game.moveMinotaur();
                else minotaurGo();

In minotaurGo, we'll create a timer called minoTimer declared as:

    public Timer minoTimer;

And now, in minotaurGo, we'll check to see if it's created, and if not, we'll create and schedule the task of moving the miontaur:

    private void minotaurGo() {
        if(minoTimer == null) {
            minoTimer = new Timer();
            minoTimer.schedule(new TimerTask() {
                @Override
                public void run() {
                    if(game.gameState== BasicMaze.GameState.inGame) {
                        game.moveMinotaur();
                        addTransition(new Transition(minotaurToken, new Coord(game.minotaur.x, game.minotaur.y)));
                    } else minoTimer.cancel();
                }
            }, 0L,minoDelay * 1000L);
        }
    }

The first parameter to scheudule (0L) is the delay, so we could add a "head start" for the player, if we wanted to. Now we have our usual issue of resetting the game. Let's try:

                            regenerate();
                            mvm.minoTimer = null;
                            mvm.minoDelay = delay.getValue();

We should be increasingly chary of messing with one object's fields from another object, even when the connection is necessarily as intimate as a view and it's view-model.

This will work but it's got a smell, as they say.

We have another issue, which is that the delay is part of the maze-view-model, which I like because we didn't have to change our underlying BasicMaze object. What that means, however, is that it's not saved when we save our maze.

My cheesy fix is to move the field into BasicMaze:

public class BasicMaze implements Serializable {
    private int width;
. . .
    public int minotaurDelay;

Yeah, I renamed it to match with the existing minotaur field. We'll also need to set it up in assign:

    public void assign(BasicMaze source) {
        width = source.width;
. . .
        minotaurDelay = source.minotaurDelay;
        maze = source.maze;
    }

This necessitates a couple of changes in the view-model. In the key-handler:

                addTransition(new Transition(playerToken, new Coord(game.player.x, game.player.y)));
                if (game.minotaurDelay == 0) {

and in minotaurGo():

private void minotaurGo() {
. . .
            }, 0L, game.minotaurDelay * 1000L);
        }
    }

And we need to make one other change in minotaurGo because we're setting the minoTimer to null to indicate we're done with it, but that doesn't make it go away, so it'll keep firing. And if we load up a maze with a timer, we'll actually end up with two timers, making it twice as fast. So:

private void minotaurGo() {
        if (minoTimer == null) {
            minoTimer = new Timer();
            minoTimer.schedule(new TimerTask() {
                @Override
                public void run() {
                    if (minoTimer!=null && game.gameState == BasicMaze.GameState.inGame) {
                        game.moveMinotaur();
                        addTransition(new Transition(minotaurToken, new Coord(game.minotaur.x, game.minotaur.y)));
                    } else this.cancel();

                }
            }, 0L, game.minotaurDelay * 1000L);
        }
    }

We just cancel the task when minoTimer is set to null. Oh, and we use this.cancel to make sure we're not cancelling a non-existent thing.

Wrapping Up The Wrap-Up

Probably one of the worst things you can do in any project is to add a new, possibly breaking feature, and then bail on it, but that's what we're going to do here. There are a lot of interesting possibilities for expanding this and making it even more fun. (Yes, even more!)

Consider the numerous possibilities of limiting visibility: Hide the exit, hide the minotaur, hide the whole maze. Only reveal things that are close and make the user discover the map.

The minotaur would have to be a little less ruthless in these cases, or somehow handicapped, but that also raises interesting possibilities: Delay the minotaur's entrance to the maze; make the minotaur have the same entrance as the player; give the minotaur a chance to not move (when turn-based); give the minotaur a chance to move the wrong direction, or even go down a wrong path.

You could create a system whereby the minotaur was far "behind" the player, but when the player doubled-back, the minotaur moved twice. Or the minotaur could have a rage-meter that built up and allowed it to smash through a wall. (Consider the potential effect on path-finding in that case.)

The player could have a "run" ability, or a teleport spell. There could be items in dead ends that made them worth going down.

You could have a scoring system that calculated the top score as moving directly to the exit and subtracted points for wrong turns (or "missed" turns in the case of a timed-game). You could diminish the score for uses of special escapes like the aforementioned "run" or "teleport".

You could develop a combat system that allows—well, but that's getting into an entirely different game, now, isn't it?

In Conclusion

We learned a lot this time through, didn't we? We started by making a component of our very own. We integrated animations in-game. We did pathfinding. We used our knowledge gleaned from the tic-tac-toe chapter to make our game object much cleaner.

Our next game is going to more substantial, but I think we're ready to tackle it now!

"Final" code here .