FX My Life 2.7: Gamazing!
So, now that we've confessed to ourselves we need to finish up the maze game and start fresh in part 3 for "Dungeon Slippers", let's see what we can do to make this maze into a legitimate game—as well as possibly cleaning up any remaining grid issues.
Let's start with a simple end game condition: When the player arrives at the exit, he has won.
public enum GameState {preGame, inGame, postGame}
public GameState gameState = GameState.preGame;
OK, not that simple, because we have a somewhat more complex game state right now, just off the bat: Our maze can be in a preGame state before and during generation. Any attempt to move the player while not "inGame" results in false
:
public boolean movePlayer(Direction d) {
if(gameState!= GameState.inGame) return false;
. . .
Let's also add this setting to reset
:
public void reset(int w, int h) {
width = w;
height = h;
maze = null;
gameState = GameState.preGame; \\ADD THIS
alertConsumers();
}
We should also add the winning game state to movePlayer
:
if(player.equals(exit)) gameState = GameState.postGame;
The whole movePlayer
looks like:
public boolean movePlayer(Direction d) {
if(gameState!= GameState.inGame) return false;
if(get(player.y, player.x).contains(d)) {
var delta = dirToDelta(d);
player.x += delta.x;
player.y += delta.y;
if(player.equals(exit)) gameState = GameState.postGame;
alertConsumers();
return true;
} else return false;
}
And at the end of the generate
:
} while (slots > 1);
System.out.println("DONE!");
gameState = GameState.inGame;
}
So that the player can move and voila! We have a game. Let's put a little something in the maze-view model to celebrate. We'll create a little pop-up message:
private final Popup messageWin = new Popup();
private final Label messageText = new Label("Victory is yours!");
We can't design this in the SceneBuilder, because the SceneBuilder is for stuff that takes place inside a window and a Popup
is a window—a subtle but important distinction. (Though we could create an anchor pane or something and put that in the popup. We might do that later.)
Now, let's expand our message handling to see if a consumed event resulted in the player winning:
if(consume) {
keyEvent.consume();
if (game.gameState == BasicMaze.GameState.postGame) {
messageWin.show(Main.me.stage);
. . .
}
Well, that works, for some definition of "works", but MAN is it ugly.
And the popup never goes away. It just floats over literally everything
Well, we could fade it out, which could be cool. (We'll work on styling it later.) But, actually, we can't fade it out, because a fade requires a node and Popup
, as we have already noted, is a window.
We can fade out the text and then hide the window:
if(consume) {
keyEvent.consume();
if (game.gameState == BasicMaze.GameState.postGame) {
messageWin.show(Main.me.stage);
FadeTransition ft = new FadeTransition(Duration.millis(3000), messageText);
ft.setFromValue(1.0);
ft.setToValue(0.0);
ft.setOnFinished( (event) -> {messageWin.hide();});
ft.play();
}
So the text fades away while the window stays where it is until it finally blinks out (with the hide
).
We can do better than that, right? Right?
In Which We Discover Whether We Can Do Better Than That
The problem: Windows, not being nodes, can't be used in fadeTransitions
. Windows do, however, have an opacity, and that can be transitioned manually using a timeline.
Now, it seems highly improbable to me that this transition doesn't already exist somewhere, but since I can't find it, we'll roll our own for now. We might decide later to do a whole messaging toolbox for our app so all the games can use it, but for now, we'll stuff it in to the maze game.
We'll just pop the message up and gradually fade out with a specific method:
private void showMessage(String text) {
messageText.setText(text);
messageWin.setOpacity(1.0);
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();
}
OK, it still looks bad but it looks better. The whole thing fades out as a unit, at least. But it's also always center where I think it would be cool if it appeared proximal to the actual player marker.
We can do this by taking the upper left of the main window and adding the board's local coordinates. This offsets the actual popup by the height of our button panel at the top.
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);
It's a little convoluted, but basically we:
getBoundsInLocal()
which returns the boundary that defines the board within the scene. Then we:
localToScreen(...)
which returns the boundary that defines the board on the OS desktop. This gives us getMinX()
and getMinY()
will give us the board's (0, 0) in absolute desktop coordinates.
To that we use our cellLocal*
calls to put the message over the cell.
Oh, and from regenerating the maze over and over, I spotted a bug from last chapter: Our exit's going to always be in the same place after the first time it's set. I fixed it this way:
public void assignCoords () {
entrance = Coord.RandCoord(width, height);
exit = null; //added this line
while(exit==null || exit.equals(entrance)) exit = Coord.RandCoord(width, height);
player = new Coord(entrance.x, entrance.y);
}
We haven't done much with making the label look presentable, and we're not going to right now. We should probably take a whole chapter just styling this thing; perhaps we'll do that after "Dungeon Slippers"
For now, we'll settle for this:
messageText.setStyle("-fx-text-fill: black; -fx-font-size: 18pt; -fx-font-weight: bold;");
I put this in the showMessage
method because I feel like we're going to come back to this routine, and we may want to style according to some message type.
More animations
Next up, let's make the player movement a little less "digital". It just pops from one square to the next and it really should slide. This should be easy, right?
Probably the "smart" or "right" way to do it would be to hook an event to the maze game's player movement code and tie an event in that will update the object position. But since we're controlling player movement through the maze view-model, we might be able to get away with something like this:
- Record the old position
- If the new position (after processing the key command) is different, draw the transition.
It's tempting to do this all in the event handler but I fear we will end up colliding with the draw
method, so let's do it this way: We'll set up the player token to be a field we keep track of.
private Circle playerToken;
Which we'll set up in the constructor:
playerToken = new Circle();
playerToken.setFill(Color.web("0x000077", 1.0));
and now we'll redesign the draw
code that handles it:
if (game.player != null) {
var r = board.getCellDim(game.player);
playerToken.setCenterX(r.getMinX()+r.getWidth()/2);
playerToken.setCenterY(r.getMinY()+r.getHeight()/2);
playerToken.setRadius(Math.min(r.getWidth(), r.getHeight())/2);
board.piecePane.getChildren().add(playerToken);
}
This gives us...exactly what we already had! But that's okay, because we now have something we can transition from one location another, and we don't even have to care how the change came about (which suggests some interesting game possibilities).
Let's do an timeline like the one we do for fading/sliding in/out games. Our game.player
block begins:
if (game.player != null) {
var r = board.getCellDim(game.player);
var newX = r.getMinX()+r.getWidth()/2;
var newY = r.getMinY()+r.getHeight()/2;
playerToken.setCenterX(newX);
playerToken.setCenterY(newY);
Now, there's a simpler approach than using a timeline: TranslateTransition
.
if(playerToken.getCenterX()!=newX || playerToken.getCenterY()!=newY) {
TranslateTransition translate = new TranslateTransition();
translate.setByX(newX-playerToken.getCenterX());
translate.setByY(newY-playerToken.getCenterY());
translate.setDuration(Duration.millis(125));
translate.setNode(playerToken);
translate.play();
}
This is pretty straighforward looking: We use setByX
and setByY
of the TranslateTransition
to tell it where we want the token to end up, then set the duration, tell the transition object what it's acting on, PlayerToken
, and then play()
and we're good to go, right?
Actually, let's look at this entire block of code to get a better understanding of it:
if (game.player != null) {
var r = board.getCellDim(game.player);
var newX = r.getMinX() + r.getWidth() / 2;
var newY = r.getMinY() + r.getHeight() / 2;
playerToken.setRadius(Math.min(r.getWidth(), r.getHeight()) / 2);
if (playerToken.getCenterX() == 0.0) {
playerToken.setCenterX(newX);
playerToken.setCenterY(newY);
}
board.piecePane.getChildren().add(playerToken);
if(playerToken.getCenterX()!=newX || playerToken.getCenterY()!=newY) {
TranslateTransition translate = new TranslateTransition();
translate.setByX(newX-playerToken.getCenterX());
translate.setByY(newY-playerToken.getCenterY());
translate.setDuration(Duration.millis(125));
translate.setNode(playerToken);
translate.play();
}
}
In the first block we do all our calculatin'—figuring out the final size and location of the player token—and in the next block, we set the playerToken values to something, if they're not already. This handles the initial state of the maze.
After that we add the token to the board, and the last block does the transition. If you run this, though, you'll notice the token acting weirder and weirder. Now, if you'll recall back to our NodeController
, we had some code to make sure that, whatever else happened, the game board ended up in the right place.
We shouldn't have to do this, but I kind of like to anyway:
translate.setNode(playerToken);
translate.setOnFinished((e) -> { //Add this block here
playerToken.setCenterX(newX);
playerToken.setCenterY(newY);
});
translate.play();
This will—well, not necessarily make things worse, but it won't fix the problem. And if you recall the similar issue we had with NodeController
, you may be able to intuit why. (Don't feel bad if you couldn't, though: I didn't remember until I figured the problem out anew.)
The thing is, a node's transition value is permanent—it's not connected to any particular animation timeline. When we say transition an object 50 pixels to the right, but then we move the actual object 50 pixels to the right, we're going to get 100 pixels of visible movement (a smoothly animated part followed by a jump)!
If we embellish our block a little bit to reset the translate:
translate.setOnFinished((e) -> {
playerToken.setTranslateX(0);
playerToken.setTranslateY(0);
playerToken.setCenterX(newX);
playerToken.setCenterY(newY);
});
Things will look correct.
Until we try a larger maze and/or start pressing buttons very fast. Then things will start to get weird as we start another transition on top of the one we have running. The player token will jump around ahead of where it needs to land. You can probably guess why that occurs, but fixing it requires a little thought.
Animation versus Actuality
What's going on is that our animation is simply for show: Our underlying maze game doesn't have the concept of being in between spaces, right? So we've created this animation that is essentially an inaccurate view of what's actually happening in the game space. (For more real-time games, and more sophisticated games generally, we might start the animation to do the move but not update the player token until after the animation finished successfully.)
The upshot is that when we create the transition, we're creating it on the assumption that the playerToken
object maps to the underlying game.player
—but when the animation isn't complete, that isn't true.
We can make this easily apparent by lengthening the animation time:
translate.setDuration(Duration.millis(250));
Creating a field for the transition animation:
private TranslateTransition playerTransition = new TranslateTransition();
We initailize playerTransition to an object we'll never use, just so we don't have to check for null. Which we set at the bottom of the animation block:
translate.play();
playerTransition = translate;
Meanwhile, at the top of the animation block:
System.out.println(playerTransition.getStatus());
And we'll see in the console, if we start hammering the keys:
STOPPED
STOPPED
RUNNING
STOPPED
STOPPED
STOPPED
STOPPED
RUNNING
There are a lot of ways to handle this, and they all have different ramifications. For example, we can say we won't process user movement keys until after the animation is complete.
public void handle(KeyEvent keyEvent) {
if(playerTransition.getStatus()==Animation.Status.RUNNING) {
System.out.println("Too fast!");
keyEvent.consume();
return;
}
. . .
If we set the animation time down to, say, 60ms, this actually works pretty well. I'm sort of opposed to "not handling the user's keystrokes" as a general rule, but I'm okay with this.
Now, just as an alternative, what if we instead took the current transition and said, "Hey, when you're done with this animation, run this next animation"? Quite apart from the issue of how we accomplish this, the downside of this approach is that the screen doesn't reflect the underlying game state and it can accumulate discrepancies. It's possible for the user to press four direction keys so fast that the underlying state has him at (say) 4,0 while the animation shows him midway between 0,0 and 1,0.
But it seems like we shouldn't discount this approach without trying it, so let's consider what it would take. First, we'd need to keep track of the transitions, so let's make a list:
private final LinkedList<TranslateTransition> transitionQueue = new LinkedList<TranslateTransition>();
It doesn't have to be a linked list, though theoretically since we are always going to be adding to the end and dropping the beginning (a FIFO queue), this should be efficient.
Now we need to be able to add, remove and play the transitions. I imagine something like this:
public void addTransition(TranslateTransition t) {
transitionQueue.add(t);
playTransition();
}
public void removeTransition(TranslateTransition t) {
transitionQueue.remove(t);
playTransition();
}
public void playTransition() {
if(transitionQueue.size()>0 && transitionQueue.get(0).getStatus()!=Animation.Status.RUNNING) {
transitionQueue.get(0).play();
}
}
Simple, right? When we add or remove a transition, we immediately call playTransition()
which either does nothing or plays the top animation. Instead of playing the transition immediately when we create it, we instead call addTransition
, and we add a little call to remove the transition in the on-finished code:
translate.setOnFinished((e) -> {
playerToken.setTranslateX(0);
playerToken.setTranslateY(0);
playerToken.setCenterX(newX);
playerToken.setCenterY(newY);
removeTransition(translate);
});
addTransition(translate);
This almost works. Almost. If you give it a try—set the transition time to 1000 seconds to give you a lot of chance to see what's going on—you'll see we get three consecutive transitions, sure—but after the first one, they'll be screwy.
Why? Well, because of the part before the setOnFinished
code:
TranslateTransition translate = new TranslateTransition();
translate.setByX(newX - playerToken.getCenterX()); //HERE
translate.setByY(newY - playerToken.getCenterY()); //HERE
translate.setDuration(Duration.millis(1000));
translate.setNode(playerToken);
Those are operating on the location of the playerToken
at the time they're called. We can't really calculate the transition until after the last animation has finished!
So, we've been looking at this a little screwy. What we should do is preserve the coordinates, and create the transition right before playing it: We want the transition figured from the previous animation's ending place. A little sleight-of-hand with our transition queue:
private final LinkedList<Coord> transitionQueue = new LinkedList<Coord>();
Now, when we add and remove the transitions, we're dealing with coordinates:
public void addTransition(Coord t) {
transitionQueue.add(t);
playTransition();
}
public void removeTransition() {
/* if(transitionQueue.size()>0) */
transitionQueue.remove(0);
playTransition();
}
We shouldn't need to check the transitionQueue
size before removing it, since the only way it can be called is at the end of a transition that needs to be removed, but we don't quite have this figured out, so it may be helpful in finding any issues. Now, the playTransition
does all the heavy lifting:
public void playTransition() {
if(playerTransition.getStatus()!=Animation.Status.RUNNING && transitionQueue.size()>0) {
System.out.println(playerTransition.getStatus());
System.out.println(transitionQueue.get(0));
var r = board.getCellDim(transitionQueue.get(0));
var newX = r.getMinX() + r.getWidth() / 2;
var newY = r.getMinY() + r.getHeight() / 2;
TranslateTransition translate = new TranslateTransition();
translate.setByX(newX - playerToken.getCenterX());
translate.setByY(newY - playerToken.getCenterY());
translate.setDuration(Duration.millis(1000));
translate.setNode(playerToken);
translate.setOnFinished((e) -> {
playerToken.setTranslateX(0);
playerToken.setTranslateY(0);
playerToken.setCenterX(newX);
playerToken.setCenterY(newY);
removeTransition();
});
playerTransition = translate;
translate.play();
}
}
While our game.player
block in draw
becomes much lighter:
if (game.player != null) {
var r = board.getCellDim(game.player);
var newX = r.getMinX() + r.getWidth() / 2;
var newY = r.getMinY() + r.getHeight() / 2;
playerToken.setRadius(Math.min(r.getWidth(), r.getHeight()) / 2);
if (playerToken.getCenterX() == 0.0) {
playerToken.setCenterX(newX);
playerToken.setCenterY(newY);
}
board.piecePane.getChildren().add(playerToken);
if (playerToken.getCenterX() != newX || playerToken.getCenterY() != newY) {
addTransition(new Coord(game.player.x, game.player.y));
}
It has some code duplication which we'll doubtless want to clean up. But this will remove all the jerkiness from the animation.
It will also very smoothly move the playerToken
diagonally! If we monitor the addTransition
routine and from (say) a position of (0,0) move to (1, 0), and then to (1,1), and then quickly back to (1, 0) and (0,0), we'll discover that the (1,1) to (1,0) coordinate is never added! Instead, it'll go right from (1,1) to (0,0)!
Why?
Deja Vu All Over Again
Remember when we were generating the maze how JavaFX wouldn't call the draw
routine for every map change? What we discovered is that the underlying game state and the visual representation were loosely coupled: The imprecise (but useful) way to express this is that JavaFX doesn't guarantee it will call the draw
routine just because you've changed some aspect of the state.
If we want each transition to be displayed so that it represents the way our game sees the character moving, we'll have to add that transition when it occurs! In other words, when processing the key strokes. So we take the addTransition out of the game.player
block:
if (game.player != null) {
var r = board.getCellDim(game.player);
var newX = r.getMinX() + r.getWidth() / 2;
var newY = r.getMinY() + r.getHeight() / 2;
playerToken.setRadius(Math.min(r.getWidth(), r.getHeight()) / 2);
if (playerToken.getCenterX() == 0.0) {
playerToken.setCenterX(newX);
playerToken.setCenterY(newY);
}
board.piecePane.getChildren().add(playerToken);
//There used to be a couple of lines to add transitions here
}
and put it into the keyboard handler!
var consume = true;
var oldx = game.player.x; //save the old player position
var oldy = game.player.y;
switch (keyEvent.getCode()) {
case UP -> game.movePlayer(UP);
case RIGHT -> game.movePlayer(RIGHT);
case LEFT -> game.movePlayer(LEFT);
case DOWN -> game.movePlayer(DOWN);
default -> consume = false;
}
if (oldx != game.player.x || oldy != game.player.y) //compare to NEW pos
addTransition(new Coord(game.player.x, game.player.y)); //and add
Our game.player
block is now reduced to:
if (game.player != null)
board.piecePane.getChildren().add(playerToken);
Which is kind of nice.
Set to one second transitions, and you can actually walk the player through the maze and get the "You have esacped!" message while your player token is lazily poking along trying to catch up. At 60ms, though, there's not really a terrible lag.
When we restart, a new maze, however—and whenever you make a change to the maze game, you should run it through multiple cycles at different dimensions—the player token ends up at the ending of the old maze and doesn't slide to it correct location till the player moves it, so we should find a better place to set that token up.
It works out a little cheesy, but I just broke the two sections of setting the playerToken up:
public javafx.geometry.Point2D playerTokenCalc(Coord c) {
var r = board.getCellDim(c);
var newX = r.getMinX() + r.getWidth() / 2;
var newY = r.getMinY() + r.getHeight() / 2;
playerToken.setRadius(Math.min(r.getWidth(), r.getHeight()) / 2);
return new javafx.geometry.Point2D((float)newX, (float)newY);
}
(The cheesy part is setting the radius of the player token for what should be a pure function.) This gives us the new X and Y values we need, which we can use to position the token wherever we want:
public void setPlayerToken(javafx.geometry.Point2D n) {
playerToken.setTranslateX(0);
playerToken.setTranslateY(0);
playerToken.setCenterX(n.getX());
playerToken.setCenterY(n.getY());
}
Now, when we play a transition, we call the former, set up our translation, then call the latter when the transition is finished:
public void playTransition() {
if (playerTransition.getStatus() != Animation.Status.RUNNING && transitionQueue.size() > 0) {
var n = playerTokenCalc(transitionQueue.get(0)); //ADDED
TranslateTransition translate = new TranslateTransition();
translate.setByX(n.getX() - playerToken.getCenterX());
translate.setByY(n.getY() - playerToken.getCenterY());
translate.setDuration(Duration.millis(60));
translate.setNode(playerToken);
translate.setOnFinished((e) -> {
setPlayerToken(n); //ADDED
removeTransition();
});
playerTransition = translate;
translate.play();
}
And now our other bit of cheesiness, which is calling all these functions in the MazeController
object's handler for "New":
maze.assignCoords();
mvm.setPlayerToken(mvm.playerTokenCalc(new Coord(maze.player.x, maze.player.y))); //ADDED
mvm.draw();
Phew! Well, this player movement turned out to be a lot more interesting and tricky than I thought. We'll call this a chapter and do One More Maze Chapter before moving on.
Code for this section is available here .