FX My Life 3.4: It's a Trap!

FX My Life 3.4: It's a Trap!

·

16 min read

OK, we're gonna add a trap this time, but before we start, we have to clean up last lesson's messes. We did a restructuring of the code, which was cool and (I don't think) particularly challenging, but hashnode gets very, very laggy to edit at a certain length (and it's easy to end up with a lot of length when you're making app-wide code changes).

We didn't fix the placing of objects last time, so let's do that first. And our fleeing goblin doesn't actually animate, or at least not how we'd like, so we'll probably want to deal with that. That may be a bit of a mess.

Adding a simple trap like a pit is going to be a piece of cake, relatively speaking, I think.

Placing Objects

The deal is, we create a Thing in the controller with no valid location, so to place it, we should use move(), which just creates a new object with that location:

        board.setOnGridMouseClicked((var1, x, y) -> {
                    System.out.println(thing);
                    if (thing != null) {
                        if (!game.remove(thing))
                            game.add(Thing.move(thing, x, y)); //changed
                        draw();
                    }
                }
        );

Easy. Now, to place goblins, we should add a line to our FXML:

<String fx:value="Goblin" />

Looks like we had left "Breakable wall" in there from the last chapter, so we can get rid of that for now.

Now, to create a goblin for placing in the controller:

case "Goblin" -> dvm.thing = new Thing(GOBLIN, -1, -1, null, true, true, true);

(As a reminder, those last four are blocks, occupies, removeOnTouch and flees: A goblin doesn't block entrance or exit, it occupies its square, it goes away when the player hits it, and it flees when the player is adjacent.)

Wait, while this will work for adding new objects. The change to setOnGridMouseClicked will never remove anything. Can you see why?

                        if (!game.remove(thing))

Well, the incoming thing hasn't been moved yet, so it will only find a match in things if there's a matching object at -1, -1 which there should never be. So let's move the thing first and then check to remove it:

        board.setOnGridMouseClicked((var1, x, y) -> {
                    System.out.println(thing);
                    if (thing != null) {
                        var t = Thing.move(thing, x, y);
                        if (!game.remove(t))
                            game.add(Thing.move(t, x, y));
                        draw();
                    }
                }
        );

That was easy! On to animation now.

Animating

To achieve our current animation, as you'll recall, we check to see if the player's move resulted in any change of game state, then add a transition for the player's movement:

                var valid = (oh != game.history.size()) || (op != game.history_position) ;
                if (Boolean.TRUE.equals(valid)) {
                    addTransition(new DsViewModel.Transition(playerToken, new Coord(game.player().x(), game.player().y())));

Which is as simple as it is limited. What I think we need is to have our game keep a running record of things changing, much like the history. What might that look like?

TurnIDThingActionSourceDestination
10PLAYERMOVE4,44,8
13TREASUREREMOVE4,5
20PLAYERMOVE4,88,8
25GOBLINMOVE5,48,0
30PLAYERMOVE8,88,9
40PLAYERMOVE8,99,9
45GOBLINMOVE8,01,0
50PLAYERMOVE9,92,0
55GOBLINREMOVE1,0
60PLAYEREXIT2,02,2

The view-model could look at any given turn and put in all the animation transitions for that turn. So, let's get started by creating a record:

    public record Action(
            int turn,
            int ID,
            GamePiece it,
            ActionType type,
            Coord source,
            Coord dest) {
    }

We'll need an enum for ActionType:

    public enum ActionType {MOVE, REMOVE, EXIT}

We can add to this later, of course. Now let's keep a list:

    public List<Actions> actions = new ArrayList<>();

The next thing we'll need to do is actually log those actions which occur, like every time a thing is moved. We just use a variable moved currently, but let's instead keep track of the start and end locations:

private boolean moveThing(Thing thing, Direction d) {
        var start = new Coord(thing.x, thing. y);
. . . //all the move code is here
        var end = new Coord(thing.x, thing. y);
        actions.add(new Action(TURN, thing.id, thing.type, ActionType.MOVE, start, end));
        return !start.equals(end);
    }

Now we return true whenever the starting location is different from the ending location.

Of course, we've buried the lede here: We need to revamp Thing so that it has an ID that is actually unique, not just GOBLIN or TREASURE.

We could've seen this coming early on. There are games, like Chess or Checkers, where pieces of any given class are fungible but even if you were to program those, you'd need to be able to say "move THIS pawn" forward not "move ANY pawn forward, I don't care which" and in a computer game you can't physically pick the piece up to identify it. (Though a computer game where actions applied to all the pieces of the same type on the board could be interesting!)

Anyway, let's give each item a unique ID:

    private static int ID = 0;
    public static int getID() { ID++; return ID;}

    public static Thing Wall(int x, int y, Direction dir) {
        return new Thing(getID(), GamePiece.WALL, x, y, dir, false, false, false);
    }

Here we've created a tracker called ID and created a function that returns the latest ID and increments the private variable. We fix Wall by adding that function in as the first parameter. This, of course, means we have to fix the five calls in the Controller:

        game.add(new Thing(getID(), PLAYER, 4, 4, null, true, false, false));
. . .
        game.add(new Thing(getID(), TREASURE, 4, 9, null, true, true, false));
        game.add(new Thing(getID(), GOBLIN, 8, 7, null, true, true, true));
. . .
                case "Treasure" -> dvm.thing = new Thing(getID(), TREASURE, -1, -1, null, true, true, false);
                case "Goblin" -> dvm.thing = new Thing(getID(), GOBLIN, -1, -1, null, true, true, true);

Rather nicely, the only thing we have to change the view-model is in our Draw() where we're checking ID instead of the new field type:

            switch (thing.type()) {
                case WALL -> addWall(thing.x(), thing.y(), thing.blocks());
                case TREASURE -> addPiece(new Coord(thing.x(), thing.y()), Color.GOLD);
                case GOBLIN -> addPiece(new Coord(thing.x(), thing.y()), Color.WHITE);

Everything will work now and...we're right back where we were before. Which is good, if not exciting.

Waiting Our Turn

Let's create a turn counter:

public int TURN = 0;

We'll increment that at the very end of our movePlayer:

    public boolean movePlayer(Direction d) {
. . .
        TURN++;
        return moved;
    }
Now, if we save the turn in the view-model, we can convert all the all the actions for that turn into animations. Before we do that, though, we want to tie those graphical game pieces (right now, they're all just circles) to a game ID and stop treating `playerToken` specially.

I'm going to do this by taking out the `playerToken` variable. This will break all the occurrences in the code and basically stick a big red flat on everything we have to change. Let's see, we'll remove this:
```java
        playerToken = new Circle();
        playerToken.setFill(Color.web("0x000077", 1.0));

But let's note that color 0x000077 for the player. Next let's take out this, after processing a keystroke:

addTransition(new DsViewModel.Transition(playerToken, new Coord(game.player().x(), game.player().y())));

Here we'll note the addTransition call, which we'll make generic. In the draw() routine, we have this:

        if (game.player() != null)
            board.piecePane.getChildren().add(playerToken);

I think we can just bump that up to the same switch where we create the other tokens:

case PLAYER -> addPiece(new Coord(thing.x(), thing.y()), Color.web("0x000077"));

Now we have resize(). Hmmm:

    public void resize() {
        if (board.getHeight() == 0 || board.getWidth() == 0) return;
        setToken(exitToken, tokenCalc(exitToken, new Coord(game.exit.x, game.exit.y)));
        setToken(playerToken, tokenCalc(playerToken, new Coord(game.player().x(), game.player().y())));
        draw();
    }

I think we can just delete the setToken() for playerToken. Now, if we add a draw() in where we took out the addTransition call, we'll see that we still have the same game minus the animations.

We're not done, though. We need to be able to ID the tokens by their game IDs, so that's going to take another change. Let's go back to our hash-map. Yes, it burned us once before (i.e., we mis-used it) but this is a genuinely good application.

private final HashMap<Integer, Circle> tokens = new HashMap<>();

Note that we have to use an Integer as a key, not an int. This should be pretty transparent for our purposes, however. Now, how do we populate it? Seems like this is the reasonable place:

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

        for (var thing : game.things) {
            switch (thing.type()) {
                case WALL -> addWall(thing.x(), thing.y(), thing.blocks());
                case TREASURE -> addPiece(new Coord(thing.x(), thing.y()), Color.GOLD);
                case GOBLIN -> addPiece(new Coord(thing.x(), thing.y()), Color.WHITE);
                case PLAYER -> addPiece(new Coord(thing.x(), thing.y()), Color.web("0x000077"));
            }
        }

These lines are going to change a lot, I think, especially as we add in real graphics. When we clear the board, we also need to clear the tokens:

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

Now, if we change addPiece() so that it returns the token:

    public Circle addPiece(Coord loc, Color c) {
. . .
        return t;
    }

We can add that token to tokens:

        for (var thing : game.things) {
            switch (thing.type()) {
                case WALL -> addWall(thing.x(), thing.y(), thing.blocks());
                case TREASURE -> tokens.put(thing.id(), addPiece(new Coord(thing.x(), thing.y()), Color.GOLD));
                case GOBLIN -> tokens.put(thing.id(), addPiece(new Coord(thing.x(), thing.y()), Color.WHITE));
                case PLAYER -> tokens.put(thing.id(), addPiece(new Coord(thing.x(), thing.y()), Color.web("0x000077")));
            }
        }

We can't add WALLs because we made tokens have Circle values, but we can change that later. Right now, we're only looking at animating the monsters, really. We can do this now with a simple loop where we used to have the single add transition for playerToken. First we record the turn:

 public void handle(KeyEvent keyEvent) {
                var oh = game.history.size();
                var op = game.history_position;
                var ot = game.TURN; // ADD THIS
                var moved = . . .

And now we look for all the actions that took place on the turn:

                    for (var i = 0; i < game.actions.size(); i++) {
                        var a = game.actions.get(i);
                        if (ot == a.turn()) {
                            addTransition(new Transition(tokens.get(a.ID()), a.dest()));
                        }
                    }

If the action occurred on the current turn we pull the token from the tokens hash-map and "transition" the token to its new location. We don't have to check anything else in the action, sort of amusingly, but we will as soon as we want to get more elaborate with our animations.

Et voila!

DungeonSlippers5.gif

This stuff's all getting scary easy, isn't it?

Pitfalls

Hopefully the only pitfalls we experience going forward is the one we make for our games (wokka wokka!). How would we describe a pit in game terms?

public enum GamePiece {PLAYER, WALL, TREASURE, GOBLIN, PIT}

It doesn't block, occupy, flee or get removed, so we'll need a new attribute: kills. You should be an old hand at adding new attributes by now:

 public record Thing(
            int id,
. . .
            boolean kills //ADD
    ) {
        public static Thing copy(Thing t) {
            return new Thing(t.id, t.type, t.x, t.y, t.blocks, t.occupies, t.removeOnTouch, t.flees, t.kills); //ADD LAST PARAMETER
        }

        public static Thing move(Thing t, int x, int y) {
            return new Thing(t.id, t.type, x, y, t.blocks, t.occupies, t.removeOnTouch, t.flees, t.kills); //ADD LAST PARAMETER
        }
    }

And we have to go and change the same five lines in our setup code, as always. I think we don't need to rehash that here. But we will add a pit to our start-up map:

game.add(new Thing(getID(), PIT, 7, 4, null, false, false, false, true));

In the view-model we need to draw the pit, as well:

                case PIT -> tokens.put(thing.id(), addPiece(new Coord(thing.x(), thing.y()), Color.web("0x000000")));

Now how do we handle a killing piece?

I think we need a new block of code to handle it, rather than trying to shoehorn it into the existing move* routines. Much like:

            if (thing.x == exit.x && thing.y == exit.y) {
                gameState = postGame;
                break;
            }

Which, actually looking at it, has some issues since it will end the game if a goblin hits the exit!

If we put this block checking for deadly things at the location right before that code, we'll notice something:

            /* Are we in a deadly spot? */
            for (int i = things.size() - 1; i >= 0; i--) {
                var it = things.get(i);
                if (it == t) continue;
                if (it.kills) {
                    if (t.type == PLAYER) {
                        gameState = beenEaten;
                        break;
                    }
                }
            }

The game will end when the player hits the pit—but he'll also keep moving until he's actually stopped.

DungeonSlippers6.gif

Whoopsie! Well, never say day, am I right? This is happening here (and not when we hit the exit) because the break here only gets us out of the for loop while the break for the exit gets out of the outer while loop. We could use a label to break out of both loops but those of us who grew up in the Age of GOTOs get hives when we see labels.

Let's just handle it this way:

    private boolean onDeadlyGround(Thing self) {
        for (int i = things.size() - 1; i >= 0; i--) {
            var it = things.get(i);
            if (it == self || it.x != self.x || it.y != self.y) continue;
            if (it.kills) return true;
        }
        return false;
    }

And in moveThing():

            if(onDeadlyGround(thing)) {
                if (t.type == PLAYER) {
                    gameState = beenEaten;
                }
                break;
            }

Now the player has the good sense to stop when he's dead!

Wrapping Things Up

Things do seem to be speeding along, don't they? We'll wrap up this chapter handling non-players that hit the exit or a pit. To fix the exit issue, let's confirm that it exists by moving the exit:

game.exit = new Coord(8, 1);

Now when the player moves next to the goblin, it will flee and, yep, will cause the game to end with a "You have escaped" message. Not what we want. Easy to fix, right? We just make sure it's the player who's hitting the exit:

            if (thing.x == exit.x && thing.y == exit.y && thing.type == PLAYER) {
                gameState = postGame;
                break;
            }

Now, for a goblin hitting a pit, let's move the exit back to it's usual location at 2,2 and place the pit at 8,1:

game.add(new Thing(getID(), PIT, 8, 1, null, false, false, false, true));

Sure enough, when the goblin flees, it hits the pit and stops...but does not die! Well, we sort of have a mechanism for this in the Effect record. It's probably a good time to revisit that. First, we no longer need the source/dest fields, since that's part of Thing now. And we now have two effects, so we'll need a slot for that. I'm going to say we'll use ActionType for it, at least for now. Just add a couple of new things:

    public enum ActionType {MOVE, REMOVE, EXIT, TOUCHED, KILLED}

I think this is probably right since we're going to be putting this stuff into the action recording so it can be animated at some point. "REMOVE" and "KILLED" are probably too generic but we won't worry about that for now.

Let's update the exiting removeOnTouch field:

if (thing.removeOnTouch) effectList.add(new Effect(thing, it, TOUCHED));

Now we just need to add code for falling into a pit, an else block right here should do it:

            if (onDeadlyGround(thing)) {
                if (t.type == PLAYER) {
                    gameState = beenEaten;
                } else {
                    effectList.add(new Effect(t, null, KILLED));
                }
                break;
            }

And to process the KILLED effect:

    public void processEffects() {
        effectList.forEach(e -> {
            if (e.type == TOUCHED && e.effect.removeOnTouch)
                remove(e.effect);
            else if (e.type == KILLED)
                remove(e.effect);
        });
    }

And now, if we run this through we'll see...that nothing has changed? Why not? With a quick debug we can see that the effect gets added to the list—but the list is never processed! But we know it's processed because the remove-on-touch stuff still works.

Well, what's going on here is that we process the effects, then we check the after effects. The remove-on-touch is an effect, but the fleeing is a an after effect. And the after effect can end up creating more effects that need to be processed.

We can prove this by adding a single line to process effects created by afterEffects():

        processEffects();
        afterEffects();
        processEffects(); //Add this

So now the goblin falls into the pit. But this is not a good solution because the process effect might create afterEffects which might create more effects to process, etc.

This actually kind of tricky. We can't just test for process effects:

        while (effectList.size() > 0) {
            processEffects();
            afterEffects();
        }

It's totally legitimate for there to be "after" effects with no "process" effects. We can't put the afterEffects() check outside the loop either:

        while (effectList.size() > 0) {
            processEffects();
        }
        afterEffects();

Because "after" effects can cause "process" effects. And vice-versa.

Clarifying The Problem

The tempting thing is to mush the two effect types together and keep going until we're out of "effects" generally. But before we do that, let's make sure we understand why we created two types.

The first type of effect is caused by a piece touching another piece. This happens when one piece tries to move into a space occupied or blocked by another. The second type of effect occurs because the board state has changed. Right now we just have the player moving next to a piece that flees, but consider we might have the exit be "closed" until all the monsters have been killed or all the treasure picked up, or a monster that attacks when the player is nearby, or any number of other things!

The first type is caused by motion whereas the second type is a reaction to the state of the board after all the effects of one motion have occurred. Yeah, I think we need to keep them separate. We want to run processEffects() when there are effects to be processed (though it doesn't hurt to run them otherwise) and we want to run afterEffects() when the board is settled.

And if the board is changed by either, we want to do it again! So:

    public boolean movePlayer(Direction d) {
. . .
        Dunslip before;
        do {
            before = new Dunslip(this);
            processEffects();
            afterEffects();
        } while (!this.equals(before));
        recordState();
        TURN++;
        return moved;
    }

We capture the state of the game by "cloning" it with new Dunslip(this), run our effects and then check to see if the new game "equals" the old game. Of course, we need to write an equals() or Java will just look at the reference and the clone will never be equal to the original.

    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Dunslip d)) {
            return false;
        }
        return (d.width == width &&
                d.height == height &&
                d.exit.equals(exit) &&
                d.gameState == gameState &&
                d.things.equals(things) &&
                d.effectList.equals(effectList));
    }

The real "meat" here is checking things and effectList since those are the bits of state most likely to change, but it doesn't hurt to check the other attributes as well. The thing (as it were) that worries me a little is things. Things is defined as:

    public record Thing(
            int id,
            GamePiece type,
            int x,
            int y,
            Direction blocks,
            boolean occupies,
            boolean removeOnTouch,
            boolean flees,
            boolean kills
    )

And one Thing should equal another if all the fields of the Thing are the same. For int and boolean, these are simple value compares: Not a problem. But what about blocks and type? They're objects.

This turns out not to be a problem either, because we're using records. We can prove this by adding this code to our Controller:

        System.out.println("*****");
        var t = new Thing(getID(), PIT, 8, 1, null, false, false, false, true);
        System.out.println(t.equals (Thing.copy(t)));
        System.out.println("*****");

This will print out:

DUNSLIP CONTROLLER ACTIVATED
*****
true
*****

This makes sense. And it's pretty much okay because, even though Direction and GamePiece are objects, we never change them. We can't actually change the object references and the classes themselves don't actually have mutation calls as far as I know, and if they do we're not going to use them.

Actually, our first code in this section, where we removed an object by taking the basic template (for a goblin or treasure or whatever) and moving it to a particular grid space depends on this working. It made me nervous then and makes me nervous now, even though it all seems to work.

It's a bit of unneeded stress. I might in future situations prefer using an unadorned int and constants instead of Enum.

But it all works and we can wrap up this unit.

Code.