FX My Life 3.5: Gotcha!

FX My Life 3.5: Gotcha!

·

16 min read

We do need to get this into a playable state pretty soon, so we'll just add one more type of game piece, a monster that (unlike the goblin) will not run from the player but will kill the player if the player doesn't kill it first. Let's consider a few of the many possibilities:

  • Activated when the player stops on an adjacent square
  • Activated when the player moves through an adjacent square
  • Activated by diagonals, not just adjacent squares
  • Activated when the player lands on the square the monster is facing
  • Activated when the player attacks the direction the monster is facing
  • Activated when the player lands in view of the monster

"Slayaway Camp", which we've been using as a model, has enemies that fall into the last three categories: A "dumb cop" that the player may not attack directly or land on the square that it's facing, and a SWAT-style guy that shoots the player if he lands in the SWAT-guy's line-of-sight.

You might think "Hey, we can easily add all kinds of variations that aren't even in a professionally made game!" and you'd be right. But at some point the complexity introduced outweighs the fun to be had by introducing it.

Let's do the "attack the square in front" monster first. We'll call it a hobgoblin, and it will attack the player if the player attacks him straight on or ends up on the square directly in front.

But how?

On Deadlier Ground

We have a number of existing hooks for gameplay, and we'll need to use more than one to achieve the effect we want. If the player tries to move into the hobgoblin's square, for starters, we'll have that result in player death. So, we'll go through the usual ritual. Adding a HOBGOBLIN piece:

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

Adding a melees property:

public record Thing(
. . .
            boolean melees

And of course adjusting the various calls that create Thing objects directly.

As usual, I'll think, "Well, I should make a routine that's more flexible so I don't have to keep changing this," which will be followed by "There are only three calls in Dunslip that are affected, and the ones in the controller are all going to go away eventually, so is it worth it?" And I'll hold off for now.

Let's comment out the pit for the moment and create a hobgoblin instead:

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

In the view-model's draw, we'll need to draw it:

case HOBGOBLIN -> tokens.put(thing.id(), addPiece(new Coord(thing.x(), thing.y()), Color.BLUEVIOLET));

Now the starting screen looks like:

image.png

This way we can move the player to the hobgoblin's square and have that cause the player's demise. In moveThingOneSpace, I think I see an answer:

if (thing.occupies || (thing.blocks == directionComplement(d))) return false;

Remember this code? It's for walls and it says if the wall blocks in a certain direction, it prevents entry from the complementary direction (e.g., a wall facing south blocks entry from the north).

We could add a facing field that works just like blocks does, and if we wanted to have a piece that faced direction X but blocked direction Y, that would make sense. But I kind of feel like blocks can do just what we want.

If we distinguish between "occupies" and "blocks", we can do this:

            if (dest.equals(thingLoc)) {
                if (thing.blocks == directionComplement(d)) return false;
                if (thing.removeOnTouch) effectList.add(new Effect(thing, it, TOUCHED));
                if (thing.occupies) return false;
            }

Now, if movement is blocked, the piece is not touched. Which means that if we set up our hobgoblin like so:

game.add(new Thing(getID(), HOBGOBLIN, 0, 9, RIGHT, true, true, false, false, true));

We get a situation where approaching the hobgoblin from the right is merely blocked:

image.png

And approaching the hobgoblin from the top causes it to be removed:

image.png

Now, all we have to do is add an after-effect where if the piece blocks one direction and the player is sitting on that spot at the end of his turn, he gets eaten. The current afterEffects method:

    private void afterEffects() {
        for (int i = things.size() - 1; i >= 0; i--) {
            var thing = things.get(i);
            var dx = thing.x - player().x;
            var dy = thing.y - player().y;
            if (Math.abs(dx) + Math.abs(dy) == 1) {
                if (thing.flees) flee(thing, deltaToDir(dx, dy));
            }
        }
    }

So, we're already scanning the board for things that are one space from the player. Now all we have to do is say that if the thing is one space from the player, melees and is "blocking" in the right direction, the player gets et.

    private void afterEffects() {
. . .
            if (Math.abs(dx) + Math.abs(dy) == 1) {
                if (thing.melees) {
                    var d = dirToDelta(thing.blocks);
                    if(thing.x + d.x == player().x() && thing.y + d.y == player().y()) gameState = beenEaten;
                }
                if (thing.flees) flee(thing, deltaToDir(dx, dy));
. . .

Easy. Almost too easy. No, no, just the right amount of easy, in fact. That's how we want development to play out.

Let's make one more change so that it's obvious on the map how the hobgoblin is facing:

                case HOBGOBLIN -> {
                    addWall(thing.x(), thing.y(), thing.blocks(), Color.RED);
                    tokens.put(thing.id(), addPiece(new Coord(thing.x(), thing.y()), Color.BLUEVIOLET));
                }

So, we'll add a red wall to indicate the danger area. We need to overload addWall to optionally accept a color:

    public void addWall(int i, int j, Direction d, Color c) {
        var isHorz = (d == UP || d == DOWN);
        var isPlus = (d == RIGHT || d == DOWN);
        var r = drawWall(board.getWallDim(new Coord(i, j), isHorz, isPlus), c);
        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);
    }

    public void addWall(int i, int j, Direction d) {
        addWall(i, j, d, (Color.web("0x353535", 1.0)));
    }

And we'll need to change drawWall to accept a color:

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

And this will give us:

image.png

Which is good for now. We will want to update our graphics soon, though.

We'll add the hobgoblin piece to the board, much as we did the walls. This isn't great, because we have to have one piece for each facing, in the FXML:

                                 <String fx:value="Hobgoblin (left)" />
                                 <String fx:value="Hobgoblin (right)" />
                                 <String fx:value="Hobgoblin (up)" />
                                 <String fx:value="Hobgoblin (down)" />

And the in the Dunslip switch, which we can cut-and-paste from the walls:

                case "Hobgoblin (left)" -> dvm.thing = Dunslip.Hobgoblin(-1, -1, LEFT);
                case "Hobgoblin (right)" -> dvm.thing = Dunslip.Hobgoblin(-1, -1, RIGHT);
                case "Hobgoblin (up)" -> dvm.thing = Dunslip.Hobgoblin(-1, -1, UP);
                case "Hobgoblin (down)" -> dvm.thing = Dunslip.Hobgoblin(-1, -1, DOWN);

And now we need a Hobgoblin routine, like our Wall routine:

    public static Thing Hobgoblin(int x, int y, Direction dir) {
        return new Thing(getID(), HOBGOBLIN, x, y, dir, true, true, false, false, true);
    }

OK, that was pretty easy. Let's do one more for fun.

Eye Am Watching You

For our last monster, we'll add an Evil Eye. The Evil Eye's special characteristic will be that if the player ends up in its line of sight, that's game over. I'm not sure how it could happen exactly, but we'll also make it like a hobgoblin, in that it cannot be attacked head on.

So, here we go again. New piece:

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

New characteristic (after melees):

boolean snipes

Both copy and move need updating:

        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, t.melees, t.snipes);
        }

        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, t.melees, t.snipes);
        }

We gotta update the Wall and Hobgoblin routines, and we might as well add one for adding Evil Eyes, too:

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

    public static Thing Hobgoblin(int x, int y, Direction dir) {
        return new Thing(getID(), HOBGOBLIN, x, y, dir, true, true, false, false, true, false);
    }

    public static Thing Evil_Eye(int x, int y, Direction dir) {
        return new Thing(getID(), EVIL_EYE, x, y, dir, true, true, false, false, true, true);
    }

Now we have to add false all our controller's game.add calls. We've done this often enough by this point we don't need to show the listing (and you can check the code if you have trouble).

We'll draw it in like we did with the hobgoblins—a piece and a wall:

 case EVIL_EYE -> {
                    addWall(thing.x(), thing.y(), thing.blocks(), Color.BLUE);
                    tokens.put(thing.id(), addPiece(new Coord(thing.x(), thing.y()), Color.GREEN));
                }

Let's move our starting treasure up one spot and put the evil eye beneath it:

        game.add(new Thing(getID(), TREASURE, 4, 8, null, true, true, false, false, false, false));
        game.add(Dunslip.Evil_Eye(4, 9, UP));

Since we already made the Evil_Eye routine, we can at least spare ourselves the litany of trues and falses.This yields:

image.png

And if we slide around we see we can attack from the side but not the front. Now, how this should behave is that when we remove the treasure, we're revealed to the evil eye which then kills the player.

We can enable the "snipe" feature from afterEffects() pretty easily:

private void afterEffects() {
. . .
        for (int i = things.size() - 1; i >= 0; i--) {
            if (Math.abs(dx) + Math.abs(dy) == 1) {
. . .
            } else if ((dx == 0 || dy == 0) && thing.snipes) {
                var d = dirToDelta(directionComplement(thing.blocks));
                if(d.x == 0 && ((dy >= 0) ^ (d.y < 0)) ||
                        d.y == 0 && ((dx >= 0) ^ (d.x < 0))) {
                    gameState = beenEaten;
                }
            }
        }
    }

So, in order for a thing to be lined up with the player it has to have either a zero in its x or y delta, and if it doesn't snipe, we don't care about it. The next step is to figure out whether it's facing the player, which we do with this:

                if(d.x == 0 && ((dy >= 0) ^ (d.y < 0)) ||

If the x delta is zero, we need to check the y axis. If the eye is looking down, the player has to be down. If the eye is lookin up, the player has to be up. The infrequently used XOR operator, ^, is like the && and || operators, but where && says both items have to be true, and || says at least one item has to be true, ^ exactly one item must be true. So, two trues make a false and two falses make a false, but one each is just right.

If dy comes in greater than 0 that means the player is below, but if d.y is less than zero that means the eye is looking up.

If you haven't worked with XOR, it can seem a little odd so it's not a bad idea to step through the code a few times until you have it straight. But we got bigger problems, namely that this will snipe the player even if the treasure is blocking.

Eye'm Looking Through You

We can resolve this pretty easily, though, right? We just have to look at every thing between the player and the sniping thing and block accordingly.

Something like this:

            } else if ((dx == 0 || dy == 0) && thing.snipes) {
                var d = dirToDelta(directionComplement(thing.blocks));
                if (d.x == 0 && ((dy >= 0) ^ (d.y < 0)) ||
                        d.y == 0 && ((dx >= 0) ^ (d.x < 0))) {
                    if (look(thing, thing.blocks).type == PLAYER)
                        gameState = beenEaten;
                }
            }

But how do we define look? And while, right now, we don't care particularly what is seen if it's not the player, there's a pattern here of interest. In order to figure out what the sniper can see, we:

  1. Check to see if movement between the sniper's current square and the next square over is blocked by anything in the current square.
  2. If not, see if anything in the next square over blocks entry from this direction.
  3. If not, check to see if the next square over is occupied.
  4. Go back to step 1 until we're stopped or would leave the board.

In other words, it's exactly like moving. So what if we treat it like that? Let's make a game piece called LIGHT:

public enum GamePiece {PLAYER, WALL, TREASURE, GOBLIN, PIT, HOBGOBLIN, EVIL_EYE, LIGHT}

Then, "look" will create a light particle and move it in the direction we're looking!

private Thing look(Thing thing, Direction dir) {
        var light = new Thing(getID(), LIGHT, thing.x, thing.y, null,false, false, false, false, false, false);
        return moveThing(light, dir);
    }

The last step will be to change moveThing. Our moves have been returning booleans that we've largely ignored, but what if they returned the item that stopped the movement? Not only does that allow us to solve this problem, it seems like it has potential value to later development. It does require us to dig down a bit, however.

moveThing relies on moveThingOneSpace, which now must return a Thing:

public Thing moveThingOneSpace(Thing it, Direction d) {

and furthermore has four(!) return statements that need to switch from returning booleans to returning the blocking item (or null). The first one is actually the trickiest, because we're not being stopped by a Thing, but by going off the edge of the board!

if (dest.x < 0 || dest.y < 0 || dest.x >= width || dest.y >= height) return false;

Let's come back to that. the next one is easier, we literally just have to replace false with thing:

if ((it.x == thingLoc.x) && (it.y == thingLoc.y) && (thing.blocks == d) && (thing.type != EVIL_EYE)) return thing;

This prevents a thing from exiting a location. The next code block prevents an object from entering a location, in the code we split up earlier in this chapter for the hobgoblin:

 if (dest.equals(thingLoc)) {                                                       
     if (thing.blocks == directionComplement(d)) return thing;                      
     if (thing.removeOnTouch) effectList.add(new Effect(thing, it, TOUCHED));       
     if (thing.occupies) return thing;                                              
 }

The last return indicates the movement was successful. Instead of returning true now, we'll return nulll.

 return null;

Now, going back to the first return statement, we might have been inclined to return null to indicate the user going off the board—which we can't do now because null means the move was successful. However, I think we may at some point want actual edge pieces (as well as specific types of edge pieces that count as escapes or captures or something like that), so let's create a new piece for the edge of the board:

    public enum GamePiece {PLAYER, WALL, TREASURE, GOBLIN, PIT, HOBGOBLIN, EVIL_EYE, LIGHT, EDGE}

We don't need any special characteristics. Just EDGE will do for now. Now to replace that first return:

        if (dest.x < 0 || dest.y < 0 || dest.x >= width || dest.y >= height)
            return new Thing(getID(), EDGE, dest.x, dest.y, directionComplement(d), false, false, false, false, false, false, null);

Now we can fix up movePiece, first by making it return a Thing:

private Thing moveThing(Thing thing, Direction d) {

...changing the loop through moveThingOneSpace to stop only when blocked by something:

      Thing blocker;
      while ((blocker = moveThingOneSpace(thing, d)) == null) {

We have to capture the blocker so we can return it. This style of while ((var = fn(x))... is relatively new but quite welcome.

Finally, we return whatever blocked our piece from moving:

return blocker;

Now our code above will work!

Almost.

Eye've Seen The Light

We decided to overload the blocks property to use for facing and that means that as our little light object goes out, it's immediately blocked by the evil eye's own blocks property!

My instinct here was to just add a new facing property but then I thought that might not work either—because we still want the hobgoblin to block the direction he's facing, right? Otherwise a head on charge from the player will result in the evil eye being killed, rather than the player. But is a head on charge even possible? After all, if the player ends his turn in a straight line from the evil eye, the evil eye will snipe him!

And if we're having trouble figuring this out, how will our players fare?

There's a sweet spot for games like this: Complex enough to be interesting, not so complex that no one can follow it. Let us say for now that if an object is the source of something, it cannot block that thing. (This could come back to haunt us, theoretically, if we were to, e.g. introduce mirrors or something that can redirect light, but should that arise we will, as always, adapt.)

Meanwhile, knowing the source of something seems potentially useful in other situations, so we will add a source field:

 public record Thing(
            int id,
 . . .
            Thing source
. . .

With the updates in all the usual places. Now, in look, we add the thing doing the looking:

        var light = new Thing(getID(), LIGHT, thing.x, thing.y, null,false, false, false, false, false, false, thing);

Now we're golden, right? Right?

Allllmost. If you try this out, you'll discover that it will hang up. Why? Well, the reason lies in our code to try to figure out when to stop processing effects. Remember, a big part of our game is creating chains-of-events, so we do this:

        do {
            before = new Dunslip(this);
            processEffects();
            afterEffects();
        } while (!this.equals(before));

Well, we've left a light object on the field! Well, no problem, right, we can just remove the light:

    private Thing look(Thing thing, Direction dir) {
        var light = new Thing(getID(), LIGHT, thing.x, thing.y, null,false, false, false, false, false, false, thing);
        var blocker = moveThing(light, dir);
        remove(light);
        return blocker;
    }

But this won't work. Do you know why?

Well, the light object we create on the first line doesn't exist after it's moved! Remember that when we move an object, we just destroy it and re-create it in the new spot! We've never had this exact issue before because we've never had to care about game piece mutations.

But it was this issue that caused us to create game piece IDs, so if we overload remove to make it able to remove a piece by ID rather than object identity:

    public boolean remove(int id) {
        var target = things.stream().filter(thing -> thing.id == id).findFirst().get();
        return remove(target);
    }

Now we can make our look work by calling this version of remove:

    private Thing look(Thing thing, Direction dir) {
        var light = new Thing(getID(), LIGHT, thing.x, thing.y, null,false, false, false, false, false, false, thing);
        var blocker = moveThing(light, dir);
        remove(light.id);
        return blocker;
    }

Now, if the player takes away the treasure, the evil eye "sees" them, and they are killed.

Eyes On The Prize

OK, this is a good place to end. We added two new monsters! We have enough for a decent game, but we need to do a lot of clean-up.

For one thing, it seems like we've broken our "rewind" button. For another—everything else. To get this into a playable state, we should:

  1. Fix the rewind bug and any others we may have overlooked.
  2. Make a clean division between the creation screen and the play screen.
  3. Make both pages "resettable" rather than having to back out and come back in to capture focus.
  4. Make an auto-test feature.
  5. Get our unit tests back into shape.
  6. Get some better graphics.
  7. Add graphics for the effects.
  8. Add sound effects.
  9. Allow creation of "sets" of puzzles that constitute a whole game.

Just off the top of my head. Probably all of it less fun than adding more game mechanics but a necessary (and frequently challenging).

But when we're done we'll have a legitimate game!

github.com/dsbw/fxgames/releases/tag/v3.5