JavaFX - Puzzle-Game

29.06.2013

JavaFX - Puzzle-Game

The following article describes how to create a simple Puzzle game with JavaFX. In this example MouseEvents 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:

JavaFX Dartboard

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 HBoxes 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 Rectangles 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