FX My Life 3.1: Walls

FX My Life 3.1: Walls

·

19 min read

Now that we've got a framework, we're ready to think in terms of game design. This is always fun. When you get down to it, the basic elements of this kind of puzzle are very simple, and the primary element is whether or not movement is allowed to continue or not.

Like, if we were thinking of elements of our game, we could have:

  • Walls
  • Treasure
  • Tricks and Traps
  • Monsters

Treasure could be something that stops you (either on the square or before it), then vanishes (because the player picked it up).

Tricks and traps could be things like sticky floors that stopped you when you entered a square but didn't block you from moving out of the square (unlike a wall). You could have one-way doors. Pits (which stop you like the exit but with a lose condition). Some sort of trap that changes the player's direction without stopping him. Etc.

Monsters would generally be things that stopped you (though I guess you could have a pass-through, like a ghost) and would eat you (or whatever) if you stopped near them—a different kind of stop-condition from a trap.

Now, in addition to the player motion element of a trick/trap or a monster, those features might cause non-player motion. A monster might move after the player does. Cowardly monsters might move away from the player, more aggressive monsters might move toward him. Traps could cause other elements to move, like a boulder trap, a dead fall, or who knows what else.

Even there, we're talking motion. And thinking about it, you can probably imagine a bunch of places to go with this. But we must start at the beginning, and the most basic element is: a wall. A wall prevents exit from one direction, and entrance from the complementary direction.

In other words, a north facing wall at 1,1 prevents the player at 1,1 from moving north, and the player at 1,0 from moving south.

What we'll do for all the pieces of the game as we add them is cobble a quick map together with an example of the piece, and then figure out how we want the level designer to work.

I envision this as going something like this:

 public boolean movePlayerOneSpace(Direction d) {
        Coord c = player.add(dirToDelta(d));
        if (c.x < 0 || c.y < 0 || c.x >= width || c.y >= height) return false;
        return (exiting(c.x, d) && entering(c.x, d)); //changed from "return true;"
    }

My initial thought was to call the functions "allowedToEnter" and "allowedToExit" but we might need those calls down the line—that is, a call that queries whether or not a piece can move a certain way without trying to move a certain way. These calls are "I'm walking here!" and can be used to trigger other events.

I think what we want is a hash-map. Something like this:

public HashMap<Coord, ???> objects;

As I mentioned, back in the old days we would have an object tree describing all the different branches of our object hierarchy. But this is overkill, and awkward to boot, since what we really have is a bunch of characteristics that we will (we hope) be assembling in interesting ways.

So I'm thinking we revisit the record concept from last chapter. If you'll recall, we wanted to store a list of transitions, but we didn't really want to create a whole object for it, so we created a Transition record. (We re-used that for DunSlip.)

Most of the elements of this record will actually be very simple:

    public record Object(
            Boolean scared,
            Boolean fatal,
            Boolean toggles_light,
           . . .
    }

The concept of block (exit or entry) is somewhat more complicated than a simple boolean. I mean, we could just list out blocks_entry_north, blocks_entry_south and blocks_exit_east, but this would be neater:

    public record Object(
            Set<Direction> blocksEntry,
            Set<Direction> blocksExit) {
    }

This form would be particularly expressive—it allows for concepts like one-way doors, traps that allow entry from any direction but limited exits, etc.—but looking at it, I'm beginning to think it's too much. What if we just did this:

    public record Object(Direction blocks) {   }

? This would allow us to describe...walls. Which is all we want for now. We may end up re-engineering this later, but it will serve for now. Also, let's not use Object, since that's a thing in Java already. Let's use "Thing". So, now we have:

public record Thing(Direction blocks) {
}
public HashMap<Coord, Thing> things = new HashMap<>();

And we'll need to borrow some more code from MazeViewModel! Namely, the stuff for drawing the walls. This is strictly for functional purposes, and we'll absolutely be replacing it.

private HashMap<CoordPair, Rectangle> walls;
. . .
    public Rectangle drawWall(Rectangle2D r) {
        Rectangle w = new Rectangle(r.getMinX(), r.getMinY(), r.getWidth(), r.getHeight());
        w.setFill(Color.web("0x353535", 1.0));
        board.piecePane.getChildren().add(w);
        return w;
    }

    public void addWall(int i, int j, Dunslip.Direction d) {
        var isHorz = (d == UP || d == DOWN);
        var isPlus = (d == RIGHT || d == DOWN);
        var r = drawWall(board.getWallDim(new Coord(i, j), isHorz, isPlus));
        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 draw() {
        walls = new HashMap<CoordPair, Rectangle>(); 
. . .
        board.piecePane.getChildren().clear();

        for(var entry : game.things.entrySet()) {
            addWall(entry.getKey().x, entry.getKey().y, entry.getValue().blocks());
        }

So, we crib our old drawWall and addWall, then add a thing in draw() to create the walls list. At that point, unlike the maze, which goes through every cell, we instead go through all the game objects (in this case, just walls) and draw those.

You can see how this will quickly need to be both generified and specialized.

Let's test it out by putting a wall in our game when we create it in the controller.

        game = new Dunslip(10, 10);
        game.player = new Coord(4, 4);
        game.exit = new Coord(0, 0);
        game.things.put(new Coord(2, 2), new Dunslip.Thing(Dunslip.Direction.DOWN)); //ADD!

Start up the game and we'll see:

image.png

Wow! It's almost as if building that simpler maze game has made our life making this new game that much easier!

Walls That Work

Now, if we add another wall and move the exit:

        game.exit = new Coord(2, 2);
        game.things.put(new Coord(2, 2), new Dunslip.Thing(Dunslip.Direction.DOWN));
        game.things.put(new Coord(2, 0), new Dunslip.Thing(Dunslip.Direction.LEFT));

when we swoop up and to the left, we'll recall that we haven't actually made the walls capable of stopping anything. Whoops:

image.png

In order make the wall actually stop something, we need to put some code in the movePlayerOneSpace routine. Earlier I suggested the possibility of using exiting() and entering() methods, but let's start simple:

    public boolean movePlayerOneSpace(Direction d) {
        Coord c = player.add(dirToDelta(d));
        if (c.x < 0 || c.y < 0 || c.x >= width || c.y >= height) return false;
        for(var entry : things.entrySet()) {
            if(entry.getKey().equals(player) && entry.getValue().blocks == d) return false;
        }
        return true;
    }

We go through the list of things and find the one the player is in, and if the cell he's in has any kind of object that blocks him from going the direction he wants, we say he can't move that way.

This works as expected:

DungeonSlippers3.gif

We're blocked from moving left through the wall, but not right coming the other way. This is an interesting conundrum: The way our design is, because walls are in a specific cell (and not, for example, in-between cells), we either have to check both the cell we're coming from and the cell we're going to—or we could add an extra wall:

        game.exit = new Coord(2, 2);
        game.things.put(new Coord(2, 2), new Dunslip.Thing(Dunslip.Direction.DOWN));
        game.things.put(new Coord(2, 0), new Dunslip.Thing(Dunslip.Direction.LEFT));
        game.things.put(new Coord(1, 0), new Dunslip.Thing(Dunslip.Direction.RIGHT));

This will work and has the advantage of keeping our code simple in movePlayerOne space, as well as giving us a simple mechanism to implement "one-way doors". The cost is that it makes our creation and destruction code more complex as every ordinary wall is actually two walls that must be treated as a single unit.

Furthermore, we're probably going to have to look into the destination cell anyway, as we add more complex objects to the game. So, for now, we'll skip the double-wall idea and change our wall-check code to:

        for (var entry : things.entrySet()) {
            if (entry.getKey().equals(player) && entry.getValue().blocks == d) return false;
            if (entry.getKey().equals(c) && entry.getValue().blocks == directionComplement(d)) return false;
        }

Here's how I wrote directionComplement():

    public Direction directionComplement(Dunslip.Direction dir) {
        return (dir == UP) ? DOWN :
                (dir == DOWN) ? UP :
                        (dir == LEFT) ? RIGHT :
                                LEFT;
    }

I don't think that's very idiomatic. Wait, let's try that again with the new switch style:

    public Direction directionComplement(Dunslip.Direction dir) {
        return switch (dir) {
            case UP -> DOWN;
            case DOWN -> UP;
            case RIGHT -> LEFT;
            case LEFT -> RIGHT;
        };
    }

OK, it's a little longer but I like it better. And it works!

Designing a board

With the walls working, we have enough to build a simple puzzle, but a big part of this journey is making the tool to make the puzzle. That's probably going to be far more educational than making the game itself (though we will venture into more animation as well as sound and other special effects).

I envision, in the final product, a palette of objects that can be placed on the grid via the mouse. Let's start with three objects: the player, the exit and walls. When the designer (of the level) has either the player or exit object selected and clicks on the map, that thing is placed at that location (and removed from elsewhere).

Walls will have different characteristics, and are actually going to be "unusual" relative to the other game pieces in that they exist in-between the cells. But my first thought is that the user should click on an empty in-between space to toggle a wall: Add one if there isn't one, remove one if there is.

But thinking about it further, this might result in some very "finicky" behavior. Our grid code, you may recall, borrows for "walls" equal rows or columns from the cells the wall is between—but those "borrowed" dimensions are still part of their owning cell.

My gut tells me the easiest thing for the user will be to be able to select what kind of wall he wants (right, left, up or down). If I'm wrong, though, that's why it's called software.

I'm going to go in the FXML manually to make the following changes:

<Tab text="Designer">

This was previously untitled.

         <content>
             <GridPane>
                 <children>

For the designer, we'll want a grid pane. In (0,0), we'll put our grid:

                    <Grid prefHeight="527.0" prefWidth="767.0" colCount="3" rowCount="3" fx:id="grid" GridPane.columnIndex="0" GridPane.rowIndex="0"/>

In (0,1), we'll put a VBox which will eventually contain all our components and probably other specifics.

                     <VBox  GridPane.columnIndex="1" GridPane.rowIndex="0">
                         <Label>Components</Label>

The control we'll use to display our game pieces will be a `ListView:

                         <ListView> <items>
                             <FXCollections fx:factory="observableArrayList">
                                 <String fx:value="Wall (left)" />
                                 <String fx:value="Wall (right)" />
                                 <String fx:value="Wall (up)" />
                                 <String fx:value="Wall (down)" />
                             </FXCollections>
                         </items></ListView>
                     </VBox>
                 </children>

It'd be nice to do this graphically, of course. Like, a grid pane with one entry for each component. But we don't want to get distracted. Let's set the column constraints so that the play grid takes up 80% and the component panel takes up 20%:

                 <columnConstraints>
                     <ColumnConstraints hgrow="SOMETIMES"  percentWidth="80.0"/>
                     <ColumnConstraints hgrow="SOMETIMES"  percentWidth="20.0"/>
                 </columnConstraints>
             </GridPane>
         </content>
    </Tab>

For neatness, let's label the second tab "Player". That'll be for playing the puzzle:

    <Tab text="Player">
      <content>
        <AnchorPane minHeight="0.0" minWidth="0.0" prefHeight="180.0" prefWidth="200.0" />
      </content>
    </Tab>
  </tabs>
</TabPane>

It's a little annoying that we can't use our Grid in the SceneBuilder yet, and have to do this stuff manually, but IntelliJ makes it pretty painless. This is the result:

image.png

Wall-In

In order to use this info, we're going to have to know which item is selected in the ListView—a task which has undergone multiple revisions in FX's lifespan. We need to give the list-view an fx:id:

<ListView fx:id="complist"> <items>

IJ will complain that there is no complist and will create the field for us in our controller:

    public ListView<String> complist;

Which is cool—but why isn't there an @FXML directive? I thought that was what allowed the loader to inject the variables into the class. (I still haven't figured this out: leaving the @FXML out works for complist but not for grid.)

This gives us:

        NodeController.me.addOnActivate("dungeon-slippers", unused -> grid.requestFocus());
        complist.getSelectionModel().selectedItemProperty().addListener((obs, oldValue, newValue) -> {
            System.out.println("Selected item: "+newValue);
        });

Which, if we click on, gives us this in the console:

Selected item: Wall (left)
Selected item: Wall (right)
Selected item: Wall (up)
Selected item: Wall (down)

Great! Now what? Well, as always, let's start simple. In order to add and remove items from a cell, we need to know what items there can be in a cell, so let's put an ID in the game class's Things record:

    public record Thing(
            GamePiece id,
            Direction blocks
            ) { }

And as for GamePiece? Well, I can see two ways to go:

public enum GamePiece {leftWall, rightWall, upWall, downWall}

Or just:

public enum GamePiece {wall}

The latter means we'll have to check both id and blocks to identify what kind of wall it is, but it also means we won't be able to have a state where we have a leftWall object that blocks Up. We can always change that if it doesn't work out.

We should fix our fake setup code in the controller just to make sure we haven't broken anything:

        game.things.put(new Coord(2, 2), new Dunslip.Thing(wall, DOWN));
        game.things.put(new Coord(2, 0), new Dunslip.Thing(wall, LEFT));

Always great to add features to code that...take us right back to where we were!

Now how to listen for a click in our view model? Well, we've already made some drag-and-drop events for Grid, you may recall, so let's copy that practice for now (and hope it isn't a horrible idea). First we need to make a GridMouseEvent type:

package fxgames;

import javafx.scene.input.MouseEvent;

@FunctionalInterface
public interface GridMouseEvent {
    public void handle(MouseEvent var1, int x, int y);
}

Now we need fields for the event in Grid:

    public GridMouseEvent onGridMouseClicked;
    public void setOnGridMouseClicked(GridMouseEvent e) {this.onGridMouseClicked = e;}

And then in Grid's constructor, we'll add this code, similar to the drag events:

        this.setOnMouseClicked(event -> {
            setHoverCoord(event.getX(), event.getY());
            if (onGridMouseClicked != null)
                onGridMouseClicked.handle(event, hoverCoord.x, hoverCoord.y);
        });

We can handle this event easily in the DsViewModel. We just need to have the controller let the view-model know what kind of object we're talking about. Let's give the view-model a place to store what the cursor is currently "holding":

public Thing thing;

And now, in the controller, we can do this:

        complist.getSelectionModel().selectedItemProperty().addListener((obs, oldValue, newValue) -> {
            switch(newValue) {
                case "Wall (left)" -> dvm.thing = new Thing(wall, LEFT);
                case "Wall (right)" -> dvm.thing = new Thing(wall, RIGHT);
                case "Wall (up)" -> dvm.thing = new Thing(wall, UP);
                case "Wall (down)" -> dvm.thing = new Thing(wall, DOWN);
                default -> dvm.thing = null;
            }
        });

This is not great for a lot of reasons. For one, we're matching a string value in the FXML to one in the code, which will have to be exactly the same, including case and spaces, and we should probably make the ListView and this conditional map to the same list.

And I'm noticing that so far in our view-models, we pass in the controller, but we don't use it—the one exception being to get the graphics for "Tic Tac Toe".

A good rule to follow for object interactions is: Fewer is better, later is better, looser is better, and one-way is better than two-way. That is, you want as few object interactions as you can get away with, you want to decide about those interactions as late as possible (which is much, much sooner in a statically typed language like Java versus a dynamically typed one like Clojure), you want the connection to be as permissive as you can get away with (also harder in static languages), and when you have a direct connection, it's best if it goes one-way—that is, it's better if when Object A needs access to Object B that Object B not need to know about Object A.

You can't get away with no interaction between parts of your program, obviously, and in fact the desires for composability and modularity demand interaction. These are where the breakages occur, however, so it's best to make them rare, safe and robust.

I think what we're seeing here is that we can have a view-model for our grid, e.g., and it really doesn't need to have access to the Controller that creates it. So, we'll put that on the "refactor" list for when we're done with the game.

Toggling Walls

Now to actually add and remove the walls! The view-model stuff I want to look like this:

        board.setOnGridMouseClicked((var1, x, y) -> {
                    if (thing != null) {
                        if (!game.remove(x, y, thing))
                            game.add(x, y, thing);
                        draw();
                    }
                }
        );

So, if the user has selected an item type (in this case only walls are available, but you can see how this will work with more types), then we remove that item from the square he clicks on. If it can't be removed (because it's not there, presumably), we'll add it.

Super simple, but it should get the job done, once we write the add and remove code. We could manipulate the game's list directly in the view-model controller, but that violates the idea of "looser is better". (Or, more formally, the concept of encapsulation, the idea that implementation details of an object should be hidden from consumers of that object.)

An add method is easy enough:

    public boolean add(int x, int y, Thing thing) {
        if(things.containsValue(thing)) return false;
        things.put(new Coord(x, y), thing);
        return true;
    }

But a remove method is even easier:

    public boolean remove(int x, int y, Thing thing) {
        return things.remove(new Coord(x, y), thing);
    }

This is cool because it does exactly what we want: Removes the item at that coordinate—but only if it matches the value we passed.

However, this reveals a glaring problem with our code.

Quelle horreur !

Our HashMap is set up as:

public HashMap<Coord, Thing> things = new HashMap<>();

Which associates a coordinate with a single thing. Which means there can be only one thing at any location. Well, that's not going to work at all. The way our code is currently, the remove won't work at all, though we can add a "left" and an "up" wall, since those don't exist anywhere on the map.

It can be jarring to have a sudden confusion bring you to an abrupt stop, and even traumatic to think you've wasted time chasing an inadequate solution, but always remember it's called software. Not to go all fortune cookie on you, but you want to be the reed that bends with the wind, rather than the oak that breaks.

Our things field should have been described as:

public HashMap<Coord, List<Thing>> things = new HashMap<>();

And our add should be:

    public boolean add(int x, int y, Thing thing) {
        var c = new Coord(x, y);
        things.computeIfAbsent(c, k -> new ArrayList<>());
        var l = things.get(c);
        if(l.contains(thing)) return false;
        l.add(thing);
        return true;
    }

which is necessarily more complicated because we have to make sure there's a list of items associated with that coordinate, and create one if there isn't. An alternative would be to create lists for every cell when the game is initialized, and the code would end up looking like:

        if(things.get(new Coord(x, y)).contains(thing)) return false
        l.add(thing);
        return true;

Might be worth it. For now, we'll leave it as is. Our remove now looks like:

    public boolean remove(int x, int y, Thing thing) {
        var c = new Coord(x, y);
        var l = things.get(c);
        if(l==null) return false;
        return (l.remove(thing));
    }

This solves our problem, basically, except for one teensy-tinesy-but-ever-so-crucial thing:

    public boolean movePlayerOneSpace(Direction d) {
. . .
        for (var entry : things.entrySet()) {
            if (entry.getKey().equals(player) && entry.getValue().blocks == d) return false;
            if (entry.getKey().equals(c) && entry.getValue().blocks == directionComplement(d)) return false;
. . .

We can't check the hash value directly any more because it provides a list of things, not any actual thing. This is one of those situations that screams out for functional programming, so we'll use stream:

    public boolean movePlayerOneSpace(Direction d) {
        Coord c = player.add(dirToDelta(d));
        if (c.x < 0 || c.y < 0 || c.x >= width || c.y >= height) return false;
        for (var entry : things.entrySet()) {
            if (entry.getKey().equals(player) &&
                    entry.getValue().stream().anyMatch(thing -> thing.blocks == d)) return false;
            if (entry.getKey().equals(c) &&
                    entry.getValue().stream().anyMatch(thing -> thing.blocks == directionComplement(d))) return false;
        }
        return true;
    }

Without needing to know what kind of thing something is or what other attributes a thing might have, we're just scanning for any thing that matches a block in the right direction.

Now, we do have two other problems. If you recall, in the view-model, we do this:

                        if (!game.remove(x, y, thing))
                            game.add(x, y, thing);

And we talked about encapsulation and object interaction. Well, in a kind of "instant karma" moment, this code we wrote doesn't need to change. But some other code does. First we have the draw() code for walls:

        for (var entry : game.things.entrySet()) {
            addWall(entry.getKey().x, entry.getKey().y, entry.getValue().blocks());
        }

Well, there's no great way around this, currently. We just need to adapt to having a list instead of a single entry.

        for (var entry : game.things.entrySet()) {
            for(var thing : entry.getValue()) {
                addWall(entry.getKey().x, entry.getKey().y, thing.blocks());
            }
        }

Our other problem is a reflection of messing around inside another object's internals, when we set up our few test walls.

        game.things.put(new Coord(2, 2), new Thing(wall, DOWN));
        game.things.put(new Coord(2, 0), new Thing(wall, LEFT));

This is a good demonstration of why encapsulation is important, though it's not very important here, as this was just throwaway code we put in for testing purposes. Nonetheless, let's fix it:

        game.add(2, 2,  new Thing(wall, DOWN));
        game.add(2, 0, new Thing(wall, LEFT));

That's a cleaner look, anyway. Now, if you run this, you'll discover—well, the behavior's not quite right. We can add pieces, but we can't ever seem to remove them!

Hashed Codes

Going way back to not long after we first created Coord, we had to create an equals method. This let us say that two coordinates were equal if they had the same x and y values.

However, this is not adequate for hash-oriented storage. Container types that store things by hash put each item they hold into a bucket based on their hash code. Right now, Java is providing the hash code for our Coords, and it may as well be a random number. In this system, even though:

(new Coord(1,1).equals(new Coord(1,1)));

The equals method will never be called because the new coordinate is going to tell HashMap to look in the wrong bucket!

(new Coord(1,1).hashCode()==new Coord(1,1).hashCode); //returns FALSE!

It's a simple fix. For a hash map with a lot of entries, you can devote a ton of time (fruitfully) to optimizing how hashes are generated, but for our purposes, we just need to make sure we're looking the right bucket, so, in Coord:

    @Override
    public int hashCode() {
        return x*y;
    }

Et voila! We can now add and remove walls to our liking. (Although we do have this annoying thing where the arrow keys are controlling the tab pane rather than being passed through to the grid. We'll figure that out next time.)

We can even make a little puzzle!

image.png

See? If you move up or down, you've lost, and if you move left (and any direction other than immediately back to the right) you've lost. But if you go right, up, right, up, left, down, left, you can escape!

So, despite a couple of setbacks, we actually put in the first piece of our game pretty easily, and it should only get easier from here.

Play with this a while to see what you think of the interface: I don't like that my mind says "the wall should go closest to the cursor" but the interface puts it to (e.g.) the right side of the cell even if you're closer to the left side of it. Hmmm. Maybe just "wall" is the way to go.

This is good enough for now, though!

Code for this section can be found here .