FX My Life 3: Back To The Dungeon

·

13 min read

We're finally ready to tackle the Dungeon Slippers game! But first...

No, don't groan, there's just a little something I want to handle because as we get more games, it's apparent our top row of buttons isn't really doing the trick. The whole point of that button bar was to provide contextually relevant options, and we could probably do that but for one weird JavaFX thing. Let's say we wanted to change our "New" button to "New Game", so we type in after the "New": a space, a "G", an "a" and an "m"...so far so good:

image.png

Now we'll just add an "e" and move on...wait...wha—?!

image.png

This is such a bizarre choice for behavior, I'm at a loss to explain it. It would make sense for a vertical button bar, because then it wouldn't distort the height of the bar and create such an odd aesthetic, but button bars cannot be oriented vertically: It's just not an option.

It gets worse if you have multiple lines, as you can easily see if you switch to multi-line mode and enter "New" on one line and "Game" on the next:

image.png

This has bugged me from the get-go and has proven to be a challenge to fix, and I don't want to get too far off the path now, so I'm going to do the following hack:

    public void adjustButtons() {
        var tallest = 0.0;
        for (Node n : buttonBar.getButtons()) {
            var ht = ((Button) n).getHeight();
            if (tallest < ht) tallest = ht;
        }
        for (Node n : buttonBar.getButtons()) {
            ((Button) n).setMinHeight(tallest);
        }
    }

Basically, when we put up the button bar, we're going to check to see what button is the tallest. Then we take that height and set every other button to that. After this chapter, we'll do some serious refactoring and turn our grid in to an independent component, and maybe we'll create a button-bar that can do this on its own.

Let's call it right after showing the stage in Main:

primaryStage.show();
Controller.me.adjustButtons();

We'll probably want to call it again whenever we update the buttons, but since we're not doing that yet, we'll hold off.

Time To Slip Into Something More...Dank

So, let's get going on this game! he said, four months later.

But where to start? We want to be able to design levels and test them, and if we fire up our project and click on our helmet icon, we'll see:

image.png

OK, so the idea was to have a tab for building and a tab for playing. Seems like step one should be figuring out the game object and putting the player token (we'll call it the "knight") somewhere and making sure the basic token moves around correctly. (Recall that the primary game mechanic is that the player selects one of the four cardinal directions, and the token moves as far as it can in that direction, to the end of the board or until something obstructs it.)

Two things we can steal from the maze game are the directions and the dirToDelta method:

package fxgames.dunslip;

import fxgames.Coord;

public class Dunslip {
    private final int width;
    private final int height;
    Coord player;
    Coord exit;

    public enum Direction {UP, RIGHT, DOWN, LEFT}

    public Coord dirToDelta(Dunslip.Direction dir) {
        int mx = 0;
        int my = 0;
        switch (dir) {
            case UP -> my = -1;
            case DOWN -> my = 1;
            case RIGHT -> mx = 1;
            case LEFT -> mx = -1;
        }
        return new Coord(mx, my);
    }
}

Here, we could have either referenced the BasicMaze attributes directly or spun off a direction class, and in any business context, we would almost certainly want to make the separate class. We may yet here, too, but it's also possible that we'll end up discovering that there's something completely incompatible between the two games.

The smart play is probably to make that class but the risk is minimal and keeps us from having to refactor the maze game right now. It's a low-level technical debt, if it's debt at all, because there's really not much chance for it to grow.

The real challenge is going to be keeping IntelliJ from adding in BasicMaze, which it really wants to do and we really don't want it to do.

We can use the basic form of the movePlayer method, too, and some of the ideas:

    public boolean movePlayer(Direction d) {
        if (gameState != GameState.inGame) return false;
. . .
            if (player.equals(exit)) gameState = GameState.postGame;
            alertConsumers();
            return true;
        } else return false;
    }

Ah, yes, we'll definitely need a gameState, and we may as well reuse the inGame and postGame concepts—actually, all the states, plus we'll add one:

public enum GameState {design, preGame, inGame, beenEaten, postGame}

I tihnk beenEaten is a fine enough loss condition, even if the player isn't being eaten per the game's narrative (but merely mauled or crushed or whatever). I added the design mode because it seems like we're going to want a condition which allows the game state to be arbitrary affected.

The way we've been designing games up until now, of course, we've always been able to arbitrarily affect the game state. That's probably not "best practices".

Anyway, this gives us a starting constructor:

    public Dunslip(int w, int h) {
        width = w;
        height = h;
        gameState = GameState.preGame;
    }

And we're going to want a list of consumers:

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

Which is transient of course, because we don't stream it. And we'll crib all the functions for consumers:

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

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

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

At this point, moreso than for the directions stuff, I feel like we have the beginnings of—well, not really a basic game framework, nothing that sophisticated, but maybe an alert system class? All our game objects are going to need to notify their lists of things, and in the case of the maze, we had two lists depending on who needed to know about the maze being generated versus the game state changing during play.

I'm not sure that would actually reduce code, though. This stuff's already pretty slim.

Now, about actually moving the player. In this game, the idea is that the player moves in the selected direction until he hits the edge of the board or a barrier. This is going to have to get more complicated, but let's start with this simple idea:

    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 true;
    }

If they try to move off the board, we return false and that's where the movement stops. (We could simplify this if statement, as IntelliJ helpfully reminds us, but we're going to be adding a lot more shortly.)

We'll need to add an add method to Coord:

    public Coord add(Coord operand) {
        return (new Coord(x+operand.x, y+operand.y));
    }

While we're in there, IntelliJ says:

    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Coord)) {
            return false;
        }
        Coord c = (Coord) o;
        return (c.x == x && c.y == y);
    }

We can make c a pattern variable, which is a new one on me. What does that do?

    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Coord c)) {
            return false;
        }
        return (c.x == x && c.y == y);
    }

Oh! It allows us to declare our c as part of our instanceOf check. That's cool! Mix it all up and movePlayer looks like this:

    public boolean movePlayer(Direction d) {
        if (gameState != GameState.inGame) return false;
        var moved = false;
        while (movePlayerOneSpace(d)) {
            var delta = dirToDelta(d);
            player.x += delta.x;
            player.y += delta.y;
            moved = true;
            alertConsumers();
            if (player.equals(exit)) {
                gameState = postGame;
                break;
            }
        }
        return moved;
    }

As long as the player can move, we move him. We don't really need the moved local. We were returning it in the maze game but we didn't ever use it. We could have, though, and probably should have, e.g., to make a sound when the user hit a wall, so I'm inclined to keep that bit of information going back to our controller and view-model—when we build them, of course.

Which I guess we should!

Views Keep On Slippin'

As is our tradition, we'll begin our journey to open up the Dungeon Slippers game with a new animation transition. This time let's do a "zoom", which was all the rage back in the early '90s. This involves a scale transition, which is super easy.

            case 2 -> {
                ScaleTransition trans = new ScaleTransition(Duration.seconds(0.25), node);
                trans.setFromX(0.1);
                trans.setToX(1);
                trans.setFromY(0.1);
                trans.setToY(1);
                trans.setOnFinished(e);
                trans.play();
            }

We're saying go from 10%->100% on both the X and Y dimensions, and do it in 1/4 of a second. It looks like this:

zoom.gif

Only less obnoxious, because it's not doing the GIF thing of constantly repeating. Good lord, that's worse than a <blink> tag.

Now, on to the real thing, to get the basic token moving across the board. We have a parallel relationship here between our new view-model and the maze view-model that we had between our BasicMaze object and our Dunslip object, except that we copied less than a quarter of our BasicMaze class for Dunslip—and even that felt like a lot—and here, we're going to need to copy about 40%!

Let's go over it, quickly:

public class DsViewModel {

    private final Dunslip game;
    private final Grid board;
    private final DunslipController cont;
    private final Circle playerToken;
    private final Circle exitToken;
    public record Transition(Circle token, Coord c) {}
    private final LinkedList<DsViewModel.Transition> transitionQueue = new LinkedList<>();
    private Timeline timeline = new Timeline();
    private final Popup messageWin = new Popup();
    private final Label messageText = new Label("Victory is yours!");

We're going to keep our usual pointers to the game, the board, and the controller; we're going to create a playerToken and an exitToken as Circles, for now, though we'll embellish that later; We need the whole transition queue scheme, including the record type, the queue itself and the animation timelines, and we'll need the victory message pop-up.

This is probably all going to change by the time we're done, except for the animation transition queue.

Next, we're going to steal a big chunk for our constructor. Copying the parameters to the view-model's fields:

    public DsViewModel(Grid g, Dunslip d, DunslipController c) {
        game = d;
        board = g;
        cont = c;

Creating the player and exit tokens:

        playerToken = new Circle();
        playerToken.setFill(Color.web("0x000077", 1.0));
        exitToken = new Circle();
        exitToken.setFill(Color.web("0xFF0077", 1.0));

Making the grid the right height and turning off the mouse hover:

        board.setColCount(game.getWidth());
        board.setRowCount(game.getHeight());
        board.setHoverColor(null);

Adding ourselves to the appropriate consumer lists for both Dunslip and grid:

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

        InvalidationListener l = observable -> resize();
        g.widthProperty().addListener(l);
        g.heightProperty().addListener(l);

Creating the message popup:

        messageWin.getContent().add(messageText);
        messageText.setMinWidth(100);
        messageText.setMinHeight(20);

Now, we handle the key presses, which starts out the same:

        board.addEventHandler(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() {
            @Override
            public void handle(KeyEvent keyEvent) {
                var consume = true;
                switch (keyEvent.getCode()) {
                    case UP -> game.movePlayer(Dunslip.Direction.UP);
                    case RIGHT -> game.movePlayer(Dunslip.Direction.RIGHT);
                    case LEFT -> game.movePlayer(Dunslip.Direction.LEFT);
                    case DOWN -> game.movePlayer(Dunslip.Direction.DOWN);
                    default -> consume = false;
                }

But it ends a little simpler because we don't have a minotaur yet. This will get much more involved:

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

Now we check for game end-state:

                if (game.gameState == Dunslip.GameState.postGame) {
                    messageWin.show(Main.me.stage);
                    showMessage("You have escaped!");
                } else if (game.gameState == Dunslip.GameState.beenEaten) {
                    messageWin.show(Main.me.stage);
                    showMessage("You have been eaten!");
                }
                if (consume) keyEvent.consume();
            }
        });
    }

Well, I'm glad we didn't have to write that again, but yeah, I think we're definitely going to end up rewriting most of it to make it more specific to our game. The transition stuff is identical:

    public void addTransition(DsViewModel.Transition t) {
        transitionQueue.add(t);
        playTransition();
    }

    public void removeTransition() {
        if (transitionQueue.size() > 0)
            transitionQueue.remove(0);
        playTransition();
    }

    public void playTransition() {
        if (timeline.getStatus() != Animation.Status.RUNNING && transitionQueue.size() > 0) {
            var t = transitionQueue.get(0).token;
            var n = tokenCalc(t, transitionQueue.get(0).c);
            Duration duration = Duration.millis(125);
            timeline = new Timeline(
                    new KeyFrame(duration, new KeyValue(t.centerXProperty(), n.getX(), Interpolator.EASE_IN)),
                    new KeyFrame(duration, new KeyValue(t.centerYProperty(), n.getY(), Interpolator.EASE_IN)));
            timeline.setOnFinished((e) -> {
                removeTransition();
            });
            timeline.play();
        }
    }

It's exactly the same and for some reason IntelliJ isn't complaining. The draw() method will be greatly simplified for now:

    public void draw() {
        if(board.getHeight()==0 || board.getWidth()==0) return;
        board.setRowCount(game.getHeight());
        board.setColCount(game.getWidth());
        board.piecePane.getChildren().clear();

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

    }

With the maze, we had a more complex situation as far as drawing went: The draw() method itself was never called during the maze generation (that was removeWall), so when it was called the board had dimensions. But we only have the one method here and it'll be called at all sort of invalid times, so we short circuit. The remaining code is just more-or-less directly from the other draw().

The three token methods are identical, except that we've put exitToken where we used to have minotaurToken:

    public javafx.geometry.Point2D tokenCalc(Circle token, Coord c) {
        var r = board.getCellDim(c);
        var newX = r.getMinX() + r.getWidth() / 2;
        var newY = r.getMinY() + r.getHeight() / 2;
        token.setRadius(Math.min(r.getWidth(), r.getHeight()) / 2);
        return new javafx.geometry.Point2D((float) newX, (float) newY);
    }

    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();
    }

    public void setToken(Circle token, javafx.geometry.Point2D n) {
        token.setCenterX(n.getX());
        token.setCenterY(n.getY());
    }

And showMessage() is also identical, to the point where IJ is actually complaining about the duplicated code. (14 lines!)

    private void showMessage(String text) {
        messageText.setText(text);
        messageText.getStyleClass().add("popup-message");
        messageWin.setOpacity(1.0);
        messageWin.setX(board.localToScreen(board.getBoundsInLocal()).getMinX() + board.cellLocalX(game.player.x));
        messageWin.setY(board.localToScreen(board.getBoundsInLocal()).getMinY() + board.cellLocalY(game.player.y));
        messageWin.show(Main.me.stage);
        Timeline tl = new Timeline();
        KeyValue kv = new KeyValue(messageWin.opacityProperty(), 0.0);
        KeyFrame kf = new KeyFrame(Duration.millis(3000), kv);
        tl.getKeyFrames().addAll(kf);
        tl.setOnFinished(e -> {
            messageWin.hide();
        });
        tl.play();
    }

This gives us enough of a skeleton to see that our move code works!

DungeonSlippers2.gif

Each token movement goes as far as the board will let it, or until it hits the exit. Not bad!

On Refactoring

This is a lot of duplicated code, and you might be wondering, "Well, Blake, would you do this in real life? Or would you refactor out all the common code?" It's a fair question and the answer is a perhaps unsatisfying, "It depends."

In typical intra-office type coding, certain things, once you work them out absolutely need to be shared right away, put in a library where they can be evolved and versioned and have everyone on the same page. Other things, like the showMessage() method, perhaps, are the sorts of things you would refactor out just for neatness, though you'll definitely want to grow it so that it can be styled better. And then there's the transition queue which seems like something we might need a lot in all of our game programming, and might really be critical, especially if we want to sync up animation with sound or who knows what else.

And furthermore, neatness counts.

What mitigates against any of these calls to refactor this code? Primarily that we don't really know what we're doing yet. One bit of common code, the keyboard handling stuff, almost certainly can't be used very generically in its current form. Yes, in both the maze game and this one, we're using it to say "move the player up, down, right, left" but games can obviously get a lot more complex and have modalities where an up-arrow means something at one time and something completely different elsewhere.

If we refactor now, before we've even finished the second game, we'll have one complete use case, and one guessed-at use case, we'll have to go back into the maze game when we find ourselves needing to make changes to the refactored code, because we found we needed to change a signature or we maybe broke something.

So for now we'll live with the duplication and after we're done with Dungeon Slippers we'll do a full code review and see what we can clean up, fix or otherwise refactor.

This is a good launching spot for our next adventure so we'll end this entry and pick it up next time.

Code for the starting skeleton is here .