29.06.2013
JavaFX - Puzzle-Game
The following article describes how to create a simple Puzzle game with JavaFX. In this example MouseEvent
s are used to trigger the move of a puzzle tile from one position to the next. The EventHandler
is registered on a Rectangle
with setOnMouseClicked
. The colors of the tiles can be customized via a CSS file. Also a Property Binding is used to update the text value of a Label
automatically.
The goal of the game is to move the tiles on the left side in the same positions as the tiles on the right side. One field is empty so a mouse click on an adjacent field moves the tile on the empty position. Every move increases a counter.
Here you can see an example video of puzzle game.
First I created a simple class which stores the style of every puzzle tile (PuzzleFieldStyle
):
package org.hameister.javafx.puzzle; public class PuzzleFieldStyle { private String style; public PuzzleFieldStyle(String style) { super(); this.style = style; } public String getStyle() { return style; } public void setStyle(String style) { this.style = style; } @Override public String toString() { return style; } }
Second I created a class to store position of the puzzle tiles: (Yes, there are frameworks and classes which do the same, but it does not hurt to implement such a small lightweight class without any overhead in it. :-) )
package org.hameister.javafx.puzzle; public class Point { private int x; private int y; public Point(int x, int y) { this.x = x; this.y = y; } public int getX() { return x; } public int getY() { return y; } @Override public String toString() { return "x:"+x+" y:"+y; } }
In the next step I created a class which describes the model of the puzzle game. It stores the positions of every tile and has some functions for initialization and shuffeling the board, moving tiles, checking if the boards are equal, switching tiles and a debug output of the boards. This class containers no JavaFX code.
If you want a MVC architecture you have to move some of the functions into a controller class and register some listeners so the view reacts on changes in the model. I thought it was to much for this simple example so the model contains some controller functions.
package org.hameister.javafx.puzzle; import java.util.Random; public class PuzzleModel { private PuzzleFieldStyle[][] board; private PuzzleFieldStyle[][] expectedBoard; private int boardSize; // Indices of the neighbor fields private int[] indexX = { 0, -1, 1, 0 }; private int[] indexY = { 1, 0, 0, -1 }; public PuzzleModel(int size) { board = new PuzzleFieldStyle[size][size]; expectedBoard = new PuzzleFieldStyle[size][size]; // Simplify the range checks boardSize = board.length; init(); } private void init() { int colorCounter = 1; for (int x = 0; x < boardSize; x++) { for (int y = 0; y < boardSize; y++) { PuzzleFieldStyle color = new PuzzleFieldStyle("puzzle-field-style-no"+colorCounter++); expectedBoard[x][y] = color; board[x][y] = color; } } // One field is empty expectedBoard[boardSize-1][boardSize-1] = null; board[boardSize-1][boardSize-1] = null; shuffleBoard(); } public Point moveToEmptyField(Point moveColoredFieldToPoint) { for (int i = 0; i < 4; i++) { if (moveColoredFieldToPoint.getX() + indexX[i] >= 0 && moveColoredFieldToPoint.getY() + indexY[i] >= 0 && moveColoredFieldToPoint.getX() + indexX[i] < boardSize && moveColoredFieldToPoint.getY() + indexY[i] < boardSize) { //Check if the field is empty (null) if (board[moveColoredFieldToPoint.getX() + indexX[i]][moveColoredFieldToPoint.getY() + indexY[i]] == null) { Point emptyFieldPos = new Point(moveColoredFieldToPoint.getX() + indexX[i], moveColoredFieldToPoint.getY() + indexY[i]); switchField(moveColoredFieldToPoint, emptyFieldPos); return emptyFieldPos; } } } return null; } /** * Compare the colors of the rectangles. */ public boolean areBoardsEqual() { for (int x = 0; x < board.length; x++) { for (int y = 0; y < board.length; y++) { PuzzleFieldStyle expectedRec = expectedBoard[x][y]; PuzzleFieldStyle rect = board[x][y]; if (rect!=expectedRec || !(expectedRec == rect || expectedRec.toString().equals(rect.toString()))) { return false; } } } return true; } private void shuffleBoard() { // Per definition this is the empty field in the initial state // see colorSet4: Last value is null Point emptyFieldPos = new Point(boardSize - 1, boardSize - 1); Random r = new Random(System.currentTimeMillis()); for (int i = 0; i < 1000; i++) { int fieldToMove = r.nextInt(indexX.length); if (emptyFieldPos.getX() + indexX[fieldToMove] >= 0 && emptyFieldPos.getY() + indexY[fieldToMove] >= 0 && emptyFieldPos.getX() + indexX[fieldToMove] < boardSize && emptyFieldPos.getY() + indexY[fieldToMove] < boardSize) { Point colorFieldPos = new Point(emptyFieldPos.getX() + indexX[fieldToMove], emptyFieldPos.getY() + indexY[fieldToMove]); emptyFieldPos = switchField(colorFieldPos, emptyFieldPos); } } } private Point switchField(Point colorFieldPos, Point emptyFieldPos) { // Switch with one temp variable was possible, too. But this is // better to understand. PuzzleFieldStyle coloredField = board[colorFieldPos.getX()][colorFieldPos.getY()]; PuzzleFieldStyle emptyWhiteField = board[emptyFieldPos.getX()][emptyFieldPos.getY()]; board[emptyFieldPos.getX()][emptyFieldPos.getY()] = coloredField; board[colorFieldPos.getX()][colorFieldPos.getY()] = emptyWhiteField; return new Point(colorFieldPos.getX(), colorFieldPos.getY()); } public PuzzleFieldStyle getColorAt(int x, int y) { if (x < boardSize && x >= 0 && y < boardSize && y >= 0) { return board[x][y]; } return null; } public PuzzleFieldStyle getExpectedColorAt(int x, int y) { if (x < boardSize && x >= 0 && y < boardSize && y >= 0) { return expectedBoard[x][y]; } return null; } public void printBoards() { System.out.println("======================================================= Board"); for (int x = 0; x < boardSize; x++) { for (int y = 0; y < boardSize; y++) { if (board[y][x] != null) { System.out.print("\t" + board[y][x].toString() + "\t"); } else { System.out.print("\t\t\t\t"); } } System.out.println(); } System.out.println("======================================================= Expected"); for (int x = 0; x < boardSize; x++) { for (int y = 0; y < boardSize; y++) { if (expectedBoard[y][x] != null) { System.out.print("\t" + expectedBoard[y][x].toString() + "\t"); } else { System.out.print("\t\t\t"); } } System.out.println(); } } }
If you want to change the difficulty to solve the puzzle you can decrease the number of shuffle operations in line 80. (1000
).
The last class contains the JavaFX view components:
package org.hameister.javafx.puzzle; public class PuzzleApplication extends Application { private static final int SIZE = 4; private static final int FIELD_SIZE_PIXELS = 50; private final StringProperty counter = new SimpleStringProperty("0"); private final Text youWinText = TextBuilder.create().text("Y o u w i n! ").visible(false).styleClass("text-big-puzzle").build(); @Override public void start(Stage primaryStage) { final Label counterLabel = LabelBuilder.create().text(String.valueOf(counter.get())).styleClass("text-puzzle").layoutX(120).layoutY(20).build(); counterLabel.textProperty().bind(counter); BorderPane borderPane = new BorderPane(); Pane headerPane = new Pane(); HBox hbox = new HBox(); hbox.setPadding(new Insets(15, 12, 15, 12)); hbox.setSpacing(10); hbox.getChildren().add(TextBuilder.create().text("Counter:").styleClass("text-puzzle").x(50).y(20).build()); hbox.getChildren().add(counterLabel); headerPane.getChildren().add(hbox); VBox vBoxLeft = new VBox(); vBoxLeft.setPadding(new Insets(15, 20, 15, 20)); vBoxLeft.setSpacing(10); VBox vBoxRight = new VBox(); vBoxRight.setPadding(new Insets(15, 20, 15, 20)); vBoxRight.setSpacing(10); final Pane gamePane = new Pane(); init(gamePane, new PuzzleModel(SIZE)); AnchorPane anchorpane = new AnchorPane(); Button buttonReset = ButtonBuilder.create() .text("Reset") .styleClass("puzzle-reset-button") .onAction(new EventHandler<ActionEvent>() { @Override public void handle(ActionEvent event) { gamePane.getChildren().clear(); counter.set("0"); init(gamePane, new PuzzleModel(SIZE)); youWinText.setVisible(false); } }) .build(); HBox buttonBox = new HBox(); buttonBox.setPadding(new Insets(0, 10, 10, 10)); buttonBox.setSpacing(10); buttonBox.getChildren().add(youWinText); buttonBox.getChildren().add(buttonReset); AnchorPane.setBottomAnchor(buttonBox, 8.0); AnchorPane.setRightAnchor(buttonBox, 5.0); anchorpane.getChildren().add(buttonBox); borderPane.setTop(headerPane); borderPane.setCenter(gamePane); borderPane.setLeft(vBoxLeft); borderPane.setRight(vBoxRight); borderPane.setBottom(anchorpane); Scene scene = new Scene(borderPane, 400*1.4, 260*1.4); primaryStage.setTitle("JavaFX - Puzzle"); primaryStage.setScene(scene); primaryStage.getScene().getStylesheets().add("puzzle"); primaryStage.show(); } public void init(Pane pane, final PuzzleModel model) { for (int x = 0; x < SIZE; x++) { for (int y = 0; y < SIZE; y++) { PuzzleFieldStyle expectedColor = model.getExpectedColorAt(x, y); if (expectedColor != null) { final Rectangle recExpected = RectangleBuilder.create().x(FIELD_SIZE_PIXELS * x + 280).y(FIELD_SIZE_PIXELS * y).width(FIELD_SIZE_PIXELS).height(FIELD_SIZE_PIXELS).styleClass(expectedColor.getStyle()).build(); pane.getChildren().add(recExpected); } PuzzleFieldStyle color = model.getColorAt(x, y); if (color != null) { final Rectangle rec = RectangleBuilder.create().x(FIELD_SIZE_PIXELS * x).y(FIELD_SIZE_PIXELS * y).width(FIELD_SIZE_PIXELS).height(FIELD_SIZE_PIXELS).styleClass(color.getStyle()).build(); pane.getChildren().add(rec); rec.setOnMouseClicked(new EventHandler<MouseEvent>() { @Override public void handle(MouseEvent event) { Point moveFromPoint = new Point((int) rec.getX() / FIELD_SIZE_PIXELS, (int) rec.getY() / FIELD_SIZE_PIXELS); Point moveToPoint = model.moveToEmptyField(moveFromPoint); if (moveToPoint != null) { // Increase the counter counter.set(String.valueOf(Integer.parseInt(counter.get())+1)); moveRectangle(moveToPoint, rec); model.printBoards(); if (model.areBoardsEqual()) { youWinText.setVisible(true); } } } }); } } } } private void moveRectangle(final Point moveToPoint, final Rectangle rec) { rec.setX(moveToPoint.getX() * FIELD_SIZE_PIXELS); rec.setY(moveToPoint.getY() * FIELD_SIZE_PIXELS); } public static void main(String[] args) { launch(args); } }
The following screenshot shows the GUI of the Puzzle game with the highlighted layout components:
The number behind the counter is realized with a Label
and a StringProperty
binding. If the value of the StringProperty counter
changes its value in line 97 the Label
is updated automatically.
The layout of the GUI is realized with a BorderPane
which contains a Pane
(headerPane setTop()
) and an AnchorPane
(anchorPane setBottom()
). The Pane
for the field is placed in the center (setCenter()
). On the left (setLeft()
) and right (setRight()
) hand side I used a HBox
es as placeholders.
The init
-Method paints the two boards with the puzzle tiles. For every Rectangle
on the left hand side an EventHandler
is registered which handles onMouseClicked
-events. If the user clicks on a Rectangle
the handle
method is called and asks the model
if there is an empty neighbour field. If there is an empty field the tile is moved and the counter is increased. If both boards are equal the game ends with a success message (youWinText
).
The colors of the tiles and the stylings of the text are stored in the css file puzzle.css
:
.root { -fx-background-color:black; } .puzzle-reset-button { -fx-font: 26 arial; -fx-base: #b6e7c9; -fx-label-padding:10,0,10,0; } .puzzle-field-style-no1 { -fx-stroke: black; -fx-fill: GREENYELLOW; } .puzzle-field-style-no2 { -fx-stroke: black; -fx-fill: BLUEVIOLET; } .puzzle-field-style-no3 { -fx-stroke: BLACK; -fx-fill: YELLOW; } .puzzle-field-style-no4 { -fx-stroke: black; -fx-fill: CYAN; } .puzzle-field-style-no5 { -fx-stroke: black; -fx-fill: RED; } .puzzle-field-style-no6 { -fx-stroke: black; -fx-fill: BLUE; } .puzzle-field-style-no7 { -fx-stroke: black; -fx-fill: GREEN; } .puzzle-field-style-no8 { -fx-stroke: black; -fx-fill: DEEPPINK; } .puzzle-field-style-no9 { -fx-stroke: black; -fx-fill: FUCHSIA; } .puzzle-field-style-no10 { -fx-stroke: black; -fx-fill: LAVENDER; } .puzzle-field-style-no11 { -fx-stroke: black; -fx-fill: CHOCOLATE; } .puzzle-field-style-no12 { -fx-stroke: black; -fx-fill: INDIGO; } .puzzle-field-style-no13 { -fx-stroke: black; -fx-fill: TURQUOISE; } .puzzle-field-style-no14 { -fx-stroke: black; -fx-fill: TOMATO; } .puzzle-field-style-no15 { -fx-stroke: black; -fx-fill: STEELBLUE; } .text-puzzle { -fx-fill: RED; -fx-stroke: RED; -fx-text-fill: RED; -fx-font-family: Verdana; -fx-font-weight: bold; -fx-font-size: 20.0; } .text-big-puzzle { -fx-fill: RED; -fx-stroke: GREENYELLOW; -fx-text-fill: RED; -fx-stroke-width: 3.0; -fx-font-family: Verdana; -fx-font-weight: bold; -fx-font-size: 36.0; }
Here are some additional features which you can add to the application:
- If you don't like the rectangle tiles, you can replace them with
Circle
objects with gradients and dropshadow. - You can add animations for the moving of the
Rectangle
s so they look more smooth. - You can add a stopwatch instead of the
counter
.
If you like to add these features feel free to checkout the sourcecode from GitHub, add the features and create a pull request... :-)
The source code can be found at GitHub: GitHub JavaFXPuzzle