Musings about Java and Swing

2048 Game in Java Swing

Recently, I came across a game called 2048. The game was created by Gabriele Cirulli. It’s an interesting variation on a 4 x 4 sliding puzzle.

Here’s a screenshot of my Java Swing version of 2048. It will help you understand the rules.

2048 Game

The 2048 game starts with an empty 4 x 4 grid. Two tiles are placed on the grid at random locations. The tiles will either have a 2 value or a 4 value. There’s a 90% chance that a new tile will have the 2 value and a 10% chance that a new tile will have the 4 value.

You use the arrow keys, or the WASD keys if you’re left handed, to slide the tiles up, left, down, or right. All of the tiles move as far as they can in that direction.

When two tiles of the same value are next to each other, and you press the arrow key in that direction, they combine to form a tile with the next highest power of 2. For example, when two 2 tiles are in the top row and you press the right or the left arrow key, they combine to form a 4 tile. You can see in the screenshot that there are tiles with the value 2, 4, 8, and 16. Two 2 tiles combine to form a 4 tile. Two 4 tiles combine to form an 8 tile. Two 8 tiles combine to form a 16 tile. And so on.

One new 2 or 4 tile is placed in a random empty location each time the tiles are moved and / or combined.

The object of the game is to get a 2048 tile. Failing that, the object is to get as high a score as possible. You can see in the screenshot that the highest tile I’ve made is a 256 tile. The game ends when no more new tiles can be placed on the grid and no tiles can be combined.

You can find 2048 strategy tips in various places on the Internet. The main idea is to keep the highest value tiles in one of the four corners. There’s some luck involved, as the new tiles appear in random locations.

I used a model / view / controller pattern (MVC) when putting this Java Swing application together. I have 2 model classes, 5 view classes, and 5 controller classes. The MVC pattern separates the concerns and allows me to focus on one part of the Java Swing application at a time.

Let’s look first at the main application class, Game2048.

package com.ggl.game2048;

import javax.swing.SwingUtilities;

import com.ggl.game2048.model.Game2048Model;
import com.ggl.game2048.view.Game2048Frame;

public class Game2048 implements Runnable {

	@Override
	public void run() {
		new Game2048Frame(new Game2048Model());
	}
	
	public static void main(String[] args) {
		SwingUtilities.invokeLater(new Game2048());
	}

}

Short and to the point. This class does 3 things.

  1. Starts the Java Swing application on the Event Dispatch thread (EDT).
  2. Creates an instance of the game model.
  3. Creates an instance of the game JFrame.

Every Java Swing application has to do these 3 things. This type of class is how I start every Java Swing application.

Next, let’s look at the main model class, Game2048Model.

package com.ggl.game2048.model;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.util.Random;

public class Game2048Model {
	
	private static final boolean DEBUG = false;
	
	private static final int FRAME_THICKNESS = 16;
	private static final int GRID_WIDTH = 4;
	
	private boolean arrowActive;
	
	private int highScore;
	private int highCell;
	private int currentScore;
	private int currentCell;
	
	private Cell[][] grid;
	
	private Random random;
	
	public Game2048Model() {
		this.grid = new Cell[GRID_WIDTH][GRID_WIDTH];
		this.random = new Random();
		this.highScore = 0;
		this.highCell = 0;
		this.currentScore = 0;
		this.currentCell = 0;
		this.arrowActive = false;
		initializeGrid();
	}
	
	public void initializeGrid() {
		int xx = FRAME_THICKNESS;
		for (int x = 0; x < GRID_WIDTH; x++) {
			int yy = FRAME_THICKNESS;
			for (int y = 0; y < GRID_WIDTH; y++) {
				Cell cell = new Cell(0);
				cell.setCellLocation(xx, yy);
				grid[x][y] = cell;
				yy += FRAME_THICKNESS + Cell.getCellWidth();
			}
			xx += FRAME_THICKNESS + Cell.getCellWidth();
		}
	}
	
	public void setHighScores() {
		highScore = (currentScore > highScore) ? 
				currentScore : highScore;
		highCell = (currentCell > highCell) ?
				currentCell : highCell;
		currentScore = 0;
		currentCell = 0;
	}
	
	public boolean isGameOver() {
		return isGridFull() && !isMovePossible();
	}
	
	private boolean isGridFull() {
		for (int x = 0; x < GRID_WIDTH; x++) {
			for (int y = 0; y < GRID_WIDTH; y++) {
				if (grid[x][y].isZeroValue()) {
					return false;
				}
			}
		}
		return true;
	}
	
	private boolean isMovePossible() {
		for (int x = 0; x < GRID_WIDTH; x++) {
			for (int y = 0; y < (GRID_WIDTH - 1); y++) {
				int yy = y + 1;
				if (grid[x][y].getValue() == grid[x][yy].getValue()) {
					return true;
				}
			}
		}
		
		for (int y = 0; y < GRID_WIDTH; y++) {
			for (int x = 0; x < (GRID_WIDTH - 1); x++) {
				int xx = x + 1;
				if (grid[x][y].getValue() == grid[xx][y].getValue()) {
					return true;
				}
			}
		}
		
		return false;
	}
	
	public void addNewCell() {
		int value = (random.nextInt(10) < 9) ?  2 : 4;
		
		boolean locationFound = false;
		while(!locationFound) {
			int x = random.nextInt(GRID_WIDTH);
			int y = random.nextInt(GRID_WIDTH);
			if (grid[x][y].isZeroValue()) {
				grid[x][y].setValue(value);
				locationFound = true;
				if (DEBUG) {
					System.out.println(displayAddCell(x, y));
				}
			}
		}
		
		updateScore(0, value);
	}
	
	private String displayAddCell(int x, int y) {
		StringBuilder builder = new StringBuilder();
		builder.append("Cell added at [");
		builder.append(x);
		builder.append("][");
		builder.append(y);
		builder.append("].");
		
		return builder.toString();
	}
	
	public boolean moveCellsUp() {
		boolean dirty = false;
		
		if (moveCellsUpLoop())	dirty = true;
		
		for (int x = 0; x < GRID_WIDTH; x++) {
			for (int y = 0; y < (GRID_WIDTH - 1); y++) {
				int yy = y + 1;
				dirty = combineCells(x, yy, x, y, dirty);
			}
		}
		
		if (moveCellsUpLoop())	dirty = true;
		
		return dirty;
	}
	
	private boolean moveCellsUpLoop() {
		boolean dirty = false;
		
		for (int x = 0; x < GRID_WIDTH; x++) {
			boolean columnDirty = false;
			do {
				columnDirty = false;
				for (int y = 0; y < (GRID_WIDTH - 1); y++) {
					int yy = y + 1;
					boolean cellDirty = moveCell(x, yy, x, y);
					if (cellDirty) {
						columnDirty = true;
						dirty = true;
					}
				}
			} while (columnDirty);		
		}
		
		return dirty;
	}
	
	public boolean moveCellsDown() {
		boolean dirty = false;
		
		if (moveCellsDownLoop())	dirty = true;
		
		for (int x = 0; x < GRID_WIDTH; x++) {
			for (int y = GRID_WIDTH - 1; y > 0; y--) {
				int yy = y - 1;
				dirty = combineCells(x, yy, x, y, dirty);
			}
		}
		
		if (moveCellsDownLoop())	dirty = true;
		
		return dirty;
	}
	
	private boolean moveCellsDownLoop() {
		boolean dirty = false;
		
		for (int x = 0; x < GRID_WIDTH; x++) {
			boolean columnDirty = false;
			do {
				columnDirty = false;
				for (int y = GRID_WIDTH - 1; y > 0; y--) {
					int yy = y - 1;
					boolean cellDirty = moveCell(x, yy, x, y);
					if (cellDirty) {
						columnDirty = true;
						dirty = true;
					}
				}
			} while (columnDirty);		
		}
		
		return dirty;
	}
	
	public boolean moveCellsLeft() {
		boolean dirty = false;
		
		if (moveCellsLeftLoop())	dirty = true;
		
		for (int y = 0; y < GRID_WIDTH; y++) {
			for (int x = 0; x < (GRID_WIDTH - 1); x++) {
				int xx = x + 1;
				dirty = combineCells(xx, y, x, y, dirty);
			}
		}
		
		if (moveCellsLeftLoop())	dirty = true;
		
		return dirty;
	}
	
	private boolean moveCellsLeftLoop() {
		boolean dirty = false;
		
		for (int y = 0; y < GRID_WIDTH; y++) {
			boolean rowDirty = false;
			do {
				rowDirty = false;
				for (int x = 0; x < (GRID_WIDTH - 1); x++) {
					int xx = x + 1;
					boolean cellDirty = moveCell(xx, y, x, y);
					if (cellDirty) {
						rowDirty = true;
						dirty = true;
					}
				}
			} while (rowDirty);		
		}
		
		return dirty;
	}
	
	public boolean moveCellsRight() {
		boolean dirty = false;
		
		if (moveCellsRightLoop())	dirty = true;
		
		for (int y = 0; y < GRID_WIDTH; y++) {
			for (int x = (GRID_WIDTH - 1); x > 0; x--) {
				int xx = x - 1;
				dirty = combineCells(xx, y, x, y, dirty);
			}
		}
		
		if (moveCellsRightLoop())	dirty = true;
		
		return dirty;
	}

	private boolean moveCellsRightLoop() {
		boolean dirty = false;
		
		for (int y = 0; y < GRID_WIDTH; y++) {
			boolean rowDirty = false;
			do {
				rowDirty = false;
				for (int x = (GRID_WIDTH - 1); x > 0; x--) {
					int xx = x - 1;
					boolean cellDirty = moveCell(xx, y, x, y);
					if (cellDirty) {
						rowDirty = true;
						dirty = true;
					}
				}
			} while (rowDirty);		
		}
		
		return dirty;
	}
	
	private boolean combineCells(int x1, int y1, int x2, int y2,
			boolean dirty) {
		if (!grid[x1][y1].isZeroValue()) {
			int value = grid[x1][y1].getValue();
			if (grid[x2][y2].getValue() == value) {
				int newValue = value + value;
				grid[x2][y2].setValue(newValue);
				grid[x1][y1].setValue(0);
				updateScore(newValue, newValue);
				dirty = true;
			}
		}
		return dirty;
	}
	
	private boolean moveCell(int x1, int y1, int x2, int y2) {
		boolean dirty = false;
		if (!grid[x1][y1].isZeroValue() 
				&& (grid[x2][y2].isZeroValue())) {
			if (DEBUG) {
				System.out.println(displayMoveCell(x1, y1, x2, y2));
			}
			int value = grid[x1][y1].getValue();
			grid[x2][y2].setValue(value);
			grid[x1][y1].setValue(0);
			dirty = true;
		}
		return dirty;
	}
	
	private String displayMoveCell(int x1, int y1, int x2, int y2) {
		StringBuilder builder = new StringBuilder();
		builder.append("Moving cell [");
		builder.append(x1);
		builder.append("][");
		builder.append(y1);
		builder.append("] to [");
		builder.append(x2);
		builder.append("][");
		builder.append(y2);
		builder.append("].");
		
		return builder.toString();
	}
	
	private void updateScore(int value, int cellValue) {
		currentScore += value;
		currentCell = (cellValue > currentCell) ? 
				cellValue : currentCell;
	}
	
	public Cell getCell(int x, int y) {
		return grid[x][y];
	}
	
	public int getHighScore() {
		return highScore;
	}

	public int getHighCell() {
		return highCell;
	}

	public void setHighScore(int highScore) {
		this.highScore = highScore;
	}

	public void setHighCell(int highCell) {
		this.highCell = highCell;
	}

	public int getCurrentScore() {
		return currentScore;
	}

	public int getCurrentCell() {
		return currentCell;
	}

	public boolean isArrowActive() {
		return arrowActive;
	}

	public void setArrowActive(boolean arrowActive) {
		this.arrowActive = arrowActive;
	}

	public Dimension getPreferredSize() {
		int width = GRID_WIDTH * Cell.getCellWidth() + 
				FRAME_THICKNESS * 5;
		return new Dimension(width, width);
	}
	
	public void draw(Graphics g) {
		g.setColor(Color.DARK_GRAY);
		Dimension d = getPreferredSize();
		g.fillRect(0, 0, d.width, d.height);
		
		for (int x = 0; x < GRID_WIDTH; x++) {
			for (int y = 0; y < GRID_WIDTH; y++) {
				grid[x][y].draw(g);
			}
		}
	}

}

This class keeps the score, lets the rest of the game classes know whether or not the arrow keys are active, and maintains the 4 x 4 game grid. This class also draws the game grid. I know, I said earlier that I separate the model from the view. The reason that the drawing code is included in the model is that it’s easier for Java objects to draw themselves. While the drawing code is included in the model, it’s executed as a part of the view.

The code to move the tiles is a part of the model. It took me a couple of days to get the code correct for moving the tiles. I can’t recall the last time I had to use a do while loop structure.

This class has a DEBUG boolean. Setting this boolean to true activates a couple of System.out.println statements that helped me to debug the tile move code. This is one way to debug complicated logic without having to step through the code with a debugger.

Next, let’s look at the Cell model class.

package com.ggl.game2048.model;

import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Point;
import java.awt.font.FontRenderContext;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;

public class Cell {
	
	private static final int CELL_WIDTH = 120;
	
	private int value;
	
	private Point cellLocation;
	
	public Cell(int value) {
		setValue(value);
	}

	public static int getCellWidth() {
		return CELL_WIDTH;
	}
	
	public int getValue() {
		return value;
	}

	public void setValue(int value) {
		this.value = value;
	}
	
	public boolean isZeroValue() {
		return (value == 0);
	}
	
	public void setCellLocation(int x, int y) {
		setCellLocation(new Point(x, y));
	}

	public void setCellLocation(Point cellLocation) {
		this.cellLocation = cellLocation;
	}

	public void draw(Graphics g) {
		if (value == 0) {
			g.setColor(Color.GRAY);
			g.fillRect(cellLocation.x, cellLocation.y, 
					CELL_WIDTH, CELL_WIDTH);
		} else {		
			Font font = g.getFont();
			FontRenderContext frc = 
					new FontRenderContext(null, true, true);
	
			String s = Integer.toString(value);
			BufferedImage image = 
					createImage(font, frc, CELL_WIDTH, s);
			
			g.drawImage(image, cellLocation.x, cellLocation.y, null);
		}
	}
	
	private BufferedImage createImage(Font font, FontRenderContext frc,
			int width, String s) {

		Font largeFont = font.deriveFont((float) (width / 4));
		Rectangle2D r = largeFont.getStringBounds(s, frc);
		int rWidth = (int) Math.round(r.getWidth());
		int rHeight = (int) Math.round(r.getHeight());
		int rX = (int) Math.round(r.getX());
		int rY = (int) Math.round(r.getY());

		BufferedImage image = new BufferedImage(width, width,
				BufferedImage.TYPE_INT_RGB);
		
		Graphics gg = image.getGraphics();
		gg.setColor(getTileColor());
		gg.fillRect(0, 0, image.getWidth(), image.getHeight());

		int x = (width / 2) - (rWidth / 2) - rX;
		int y = (width / 2) - (rHeight / 2) - rY;
		
		gg.setFont(largeFont);
		gg.setColor(getTextColor());
		gg.drawString(s, x, y);
		gg.dispose();
		return image;
	}
	
	private Color getTileColor() {
		Color color = Color.WHITE;
		
		switch (value) {
			case 2:		color = Color.WHITE;
						break;
			case 4:		color = Color.WHITE;
						break;
			case 8: 	color = new Color(255, 255, 170);
						break;
			case 16:	color = new Color(255, 255, 128);
						break;
			case 32:	color = new Color(255, 255, 85);
						break;
			case 64:	color = new Color(255, 255, 43);
						break;
			case 128:	color = new Color(255, 255, 0);
						break;
			case 256:	color = new Color(213, 213, 0);
						break;
			case 512:	color = new Color(170, 170, 0);
						break;
			case 1024:	color = new Color(128, 128, 0);
						break;
			case 2048:	color = new Color(85, 85, 0);
						break;
			default:	color = new Color(43, 43, 0);
						break;
		}
		
		return color;
	}
	
	private Color getTextColor() {
		return (value >= 256) ? Color.WHITE : Color.BLACK;
	}
}

This class maintains the value. If it wasn’t for the drawing code, this class could be replaced by an int. The cellLocation Point and the drawing code make up the remainder of the class. The most interesting code is in the createImage method, where we attempt to center the text containing the value of the cell.

Coming up with a sequence of colors was interesting. My idea was to make the yellow color deeper and richer as the tile values increased. Unfortunately, the RGB color spectrum isn’t large enough to make 10 different distinct yellow colors just by varying the hue.

Let’s look at the view classes. The first is the Game2048Frame class.

package com.ggl.game2048.view;

import java.awt.FlowLayout;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;

import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.InputMap;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.KeyStroke;

import com.ggl.game2048.controller.DownArrowAction;
import com.ggl.game2048.controller.LeftArrowAction;
import com.ggl.game2048.controller.RightArrowAction;
import com.ggl.game2048.controller.UpArrowAction;
import com.ggl.game2048.model.Game2048Model;
import com.ggl.game2048.properties.HighScoreProperties;

public class Game2048Frame {
	
	private ControlPanel controlPanel;
	
	private Game2048Model model;
	
	private GridPanel gridPanel;
	
	private HighScoreProperties highScoreProperties;
	
	private JFrame frame;
	
	private ScorePanel scorePanel;
	
	public Game2048Frame(Game2048Model model) {
		this.model = model;
		this.highScoreProperties = new HighScoreProperties(model);
		this.highScoreProperties.loadProperties();
		createPartControl();
	}

	private void createPartControl() {
		gridPanel = new GridPanel(model);
		scorePanel = new ScorePanel(model);
		controlPanel = new ControlPanel(this, model);
		
		frame = new JFrame();
		frame.setTitle("2048");
		frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
		frame.addWindowListener(new WindowAdapter() {
			@Override
			public void windowClosing(WindowEvent event) {
				exitProcedure();
			}
		});
		
		setKeyBindings();

		JPanel mainPanel = new JPanel();
		mainPanel.setLayout(new FlowLayout());
		mainPanel.add(gridPanel);	
		mainPanel.add(createSidePanel());

		frame.add(mainPanel);
		frame.setLocationByPlatform(true);
		frame.pack();
		frame.setVisible(true);
	}

	private JPanel createSidePanel() {
		JPanel sidePanel = new JPanel();
		sidePanel.setLayout(new BoxLayout(sidePanel, 
				BoxLayout.PAGE_AXIS));
		sidePanel.add(scorePanel.getPanel());
		sidePanel.add(Box.createVerticalStrut(30));
		sidePanel.add(controlPanel.getPanel());
		return sidePanel;
	}
	
	private void setKeyBindings() {
		InputMap inputMap = 
				gridPanel.getInputMap(JPanel.WHEN_IN_FOCUSED_WINDOW);
		inputMap.put(KeyStroke.getKeyStroke("W"), "up arrow");
		inputMap.put(KeyStroke.getKeyStroke("S"), "down arrow");
		inputMap.put(KeyStroke.getKeyStroke("A"), "left arrow");
		inputMap.put(KeyStroke.getKeyStroke("D"), "right arrow");
		
		inputMap.put(KeyStroke.getKeyStroke("UP"), "up arrow");
		inputMap.put(KeyStroke.getKeyStroke("DOWN"), "down arrow");
		inputMap.put(KeyStroke.getKeyStroke("LEFT"), "left arrow");
		inputMap.put(KeyStroke.getKeyStroke("RIGHT"), "right arrow");
		
		inputMap = gridPanel.getInputMap(JPanel.WHEN_FOCUSED);
		inputMap.put(KeyStroke.getKeyStroke("UP"), "up arrow");
		inputMap.put(KeyStroke.getKeyStroke("DOWN"), "down arrow");
		inputMap.put(KeyStroke.getKeyStroke("LEFT"), "left arrow");
		inputMap.put(KeyStroke.getKeyStroke("RIGHT"), "right arrow");

		
		gridPanel.getActionMap().put("up arrow", 
				new UpArrowAction(this, model));
		gridPanel.getActionMap().put("down arrow", 
				new DownArrowAction(this, model));
		gridPanel.getActionMap().put("left arrow", 
				new LeftArrowAction(this, model));
		gridPanel.getActionMap().put("right arrow", 
				new RightArrowAction(this, model));
	}
	
	public void exitProcedure() {
		model.setHighScores();
		highScoreProperties.saveProperties();
		frame.dispose();
		System.exit(0);
	}
	
	public void repaintGridPanel() {
		gridPanel.repaint();
	}

	public void updateScorePanel() {
		scorePanel.updatePartControl();
	}

}

We get the high scores from a properties file in the constructor. Later, we’ll see the HighScoreProperties class that saves and loads the high scores.

The createPartControl method uses a JFrame to create the game window. Notice that we use a JFrame. We do not extend a JFrame. The only time you should extend a Swing component, or any other Java class, is when you want to override one or more of the class methods.

We have a WindowAdapter in the createPartControl method to listen for when the game window closes. This is so we can get the high score and write it to the properties file before we destroy the window and exit the game.

We create 3 JPanels for the game, the grid panel, the score panel, and the control panel. The score and control panels are placed in a side JPanel. The grid panel and side panel are placed in a main JPanel. The main JPanel is placed in the JFrame. You will save yourself a lot of grief if you always have a main JPanel to put the rest of your Swing components in. JFrames were not designed to hold lots of Swing components.

We define the key bindings in the JFrame class, even though they are attached to the grid panel. I could have attached these key bindings to any of the Swing components in the game. The grid panel seemed the most logical. As soon as you click on the Start Game button in the control panel, the grid panel loses focus. That’s why I have key bindings defined as WHEN_IN_FOCUSED_WINDOW.

A JPanel has WHEN_FOCUSED key bindings defined for the left and right arrow keys. I have no idea what action is defined, but it’s not my action. That’s why I defined WHEN_FOCUSED key bindings as well for the arrow keys.

Towards the bottom, there are 2 convenience methods that allow me to repaint the grid panel and update the score panel. These methods are called by the controller classes. Having these methods in the Game2048Frame class allows me to pass just the instance of the JFrame class to the controller classes. The controller classes don’t have to know the internals of the view classes.

Next, we’ll look at the grid panel class, GridPanel.

package com.ggl.game2048.view;

import java.awt.Graphics;

import javax.swing.JPanel;

import com.ggl.game2048.model.Game2048Model;

public class GridPanel extends JPanel {

	private static final long	serialVersionUID	= 
			4019841629547494495L;
	
	private Game2048Model model;
	
	private GameOverImage image;
	
	public GridPanel(Game2048Model model) {
		this.model = model;
		this.setPreferredSize(model.getPreferredSize());
		this.image = new GameOverImage(model);
		this.image.run();
	}

	@Override
	protected void paintComponent(Graphics g) {
		super.paintComponent(g);
		model.draw(g);
		
		if (model.isGameOver()) {
			g.drawImage(image.getImage(), 0, 0, null);
		}
	}
}

Since most of the drawing code is included in the model, there’s not much code here. We extend JPanel because we want to override the JPanel paintComponent method. If the game is over, we draw a game over image over the grid panel.

Next, we’ll look at the GameOverImage class.

package com.ggl.game2048.view;

import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.font.FontRenderContext;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;

import com.ggl.game2048.model.Game2048Model;

public class GameOverImage implements Runnable {
	
	private BufferedImage image;

	private Game2048Model model;
	
	public GameOverImage(Game2048Model model) {
		this.model = model;
	}

	@Override
	public void run() {
		String s = "Game Over";
		Dimension d = model.getPreferredSize();
		image = new BufferedImage(d.width, d.height, 
				BufferedImage.TYPE_INT_ARGB);
		Graphics2D g = image.createGraphics();

	    g.setComposite(AlphaComposite.getInstance(
	    		AlphaComposite.CLEAR));
	    
		g.setColor(Color.WHITE);
		g.fillRect(0, 0, d.width, d.height);
		
		g.setComposite(AlphaComposite.getInstance(
				AlphaComposite.SRC_OVER));
		
		g.setColor(Color.BLUE);
		Font font = g.getFont();
		Font largeFont = font.deriveFont(72.0F);
		FontRenderContext frc = 
				new FontRenderContext(null, true, true);
		Rectangle2D r = largeFont.getStringBounds(s, frc);
		int rWidth = (int) Math.round(r.getWidth());
		int rHeight = (int) Math.round(r.getHeight());
		int rX = (int) Math.round(r.getX());
		int rY = (int) Math.round(r.getY());
		
		int x = (d.width / 2) - (rWidth / 2) - rX;
		int y = (d.height / 2) - (rHeight / 2) - rY;
		
		g.setFont(largeFont);
		g.drawString(s, x, y);
		
		g.dispose();
	}

	public BufferedImage getImage() {
		return image;
	}

}

The most interesting thing here is that we have an alpha component to the image to make the majority of the image transparent. The “Game Over” text is opaque. There is also code to center the text, which is similar to the code in the Cell class to center the value text.

Next, we’ll look at the score panel class, ScorePanel.

package com.ggl.game2048.view;

import java.awt.Component;
import java.awt.Container;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.text.NumberFormat;

import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;

import com.ggl.game2048.model.Game2048Model;

public class ScorePanel {
	
	private static final Insets	regularInsets	= 
			new Insets(10, 10, 0, 10);
	private static final Insets	spaceInsets	= 
			new Insets(10, 10, 10, 10);
	
	private static final NumberFormat nf =
			NumberFormat.getInstance();
	
	private Game2048Model model;
	
	private JPanel panel;
	
	private JTextField highScoreField;
	private JTextField highCellField;
	private JTextField currentScoreField;
	private JTextField currentCellField;

	public ScorePanel(Game2048Model model) {
		this.model = model;
		createPartControl();
		updatePartControl();
	}
	
	private void createPartControl() {
		panel = new JPanel();
		panel.setLayout(new GridBagLayout());

		int gridy = 0;
		
		JLabel highScoreLabel = new JLabel("High Score:");
		addComponent(panel, highScoreLabel, 0, gridy, 1, 1, 
				regularInsets, GridBagConstraints.LINE_START, 
				GridBagConstraints.HORIZONTAL);
		
		highScoreField = new JTextField(6);
		highScoreField.setEditable(false);
		highScoreField.setHorizontalAlignment(JTextField.RIGHT);
		addComponent(panel, highScoreField, 1, gridy++, 1, 1, 
				regularInsets, GridBagConstraints.LINE_START, 
				GridBagConstraints.HORIZONTAL);
		
		JLabel highCellLabel = new JLabel("High Cell:");
		addComponent(panel, highCellLabel, 0, gridy, 1, 1, 
				spaceInsets, GridBagConstraints.LINE_START, 
				GridBagConstraints.HORIZONTAL);
		
		highCellField = new JTextField(6);
		highCellField.setEditable(false);
		highCellField.setHorizontalAlignment(JTextField.RIGHT);
		addComponent(panel, highCellField, 1, gridy++, 1, 1, 
				spaceInsets, GridBagConstraints.LINE_START, 
				GridBagConstraints.HORIZONTAL);
		
		JLabel currentScoreLabel = new JLabel("Current Score:");
		addComponent(panel, currentScoreLabel, 0, gridy, 1, 1, 
				regularInsets, GridBagConstraints.LINE_START, 
				GridBagConstraints.HORIZONTAL);
		
		currentScoreField = new JTextField(6);
		currentScoreField.setEditable(false);
		currentScoreField.setHorizontalAlignment(JTextField.RIGHT);
		addComponent(panel, currentScoreField, 1, gridy++, 1, 1, 
				regularInsets, GridBagConstraints.LINE_START, 
				GridBagConstraints.HORIZONTAL);
		
		JLabel currentCellLabel = new JLabel("Current High Cell:");
		addComponent(panel, currentCellLabel, 0, gridy, 1, 1, 
				regularInsets, GridBagConstraints.LINE_START, 
				GridBagConstraints.HORIZONTAL);
		
		currentCellField = new JTextField(6);
		currentCellField.setEditable(false);
		currentCellField.setHorizontalAlignment(JTextField.RIGHT);
		addComponent(panel, currentCellField, 1, gridy++, 1, 1, 
				regularInsets, GridBagConstraints.LINE_START, 
				GridBagConstraints.HORIZONTAL);
	}

	private void addComponent(Container container, Component component,
			int gridx, int gridy, int gridwidth, int gridheight, 
			Insets insets, int anchor, int fill) {
		GridBagConstraints gbc = new GridBagConstraints(gridx, gridy,
				gridwidth, gridheight, 1.0D, 1.0D, anchor, fill, 
				insets, 0, 0);
		container.add(component, gbc);
	}
	
	public void updatePartControl() {
		highScoreField.setText(nf.format(model.getHighScore()));
		highCellField.setText(nf.format(model.getHighCell()));
		currentScoreField.setText(nf.format(model.getCurrentScore()));
		currentCellField.setText(nf.format(model.getCurrentCell()));
	}

	public JPanel getPanel() {
		return panel;
	}
}

This class uses a GridBagLayout to lay out the labels and fields in a grid. The addComponent method creates a GridBagConstraints for each Swing component.

The updatePartControl method updates the score fields.

These are the relationships between the model, view, and controller in the MVC pattern:

  1. The view may get values from the model.
  2. The view may not update values in the model.
  3. The controller will update values in the model and may update the view.

Next, we’ll look at the control panel class, ControlPanel.

package com.ggl.game2048.view;

import java.awt.Component;
import java.awt.Container;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;

import javax.swing.JButton;
import javax.swing.JPanel;

import com.ggl.game2048.controller.StartGameActionListener;
import com.ggl.game2048.model.Game2048Model;

public class ControlPanel {
	
	private static final Insets	regularInsets	= 
			new Insets(10, 10, 0, 10);
	
	private Game2048Frame frame;
	
	private Game2048Model model;
	
	private JPanel panel;

	public ControlPanel(Game2048Frame frame, Game2048Model model) {
		this.frame = frame;
		this.model = model;
		createPartControl();
	}
	
	private void createPartControl() {
		StartGameActionListener listener = 
				new StartGameActionListener(frame, model);
		
		panel = new JPanel();
		panel.setLayout(new GridBagLayout());

		int gridy = 0;
		
		JButton startGameButton = new JButton("Start Game");
		startGameButton.addActionListener(listener);
		addComponent(panel, startGameButton, 0, gridy++, 1, 1, 
				regularInsets, GridBagConstraints.LINE_START, 
				GridBagConstraints.HORIZONTAL);
	}

	private void addComponent(Container container, Component component,
			int gridx, int gridy, int gridwidth, int gridheight, 
			Insets insets, int anchor, int fill) {
		GridBagConstraints gbc = new GridBagConstraints(gridx, gridy,
				gridwidth, gridheight, 1.0D, 1.0D, anchor, fill, 
				insets, 0, 0);
		container.add(component, gbc);
	}
	
	public JPanel getPanel() {
		return panel;
	}
}

This class uses a GridBagLayout to lay out the button in a grid. The GridBagLayout is overkill for one button, but is useful for laying out a column of buttons. The GridBagLayout ensures that all of the buttons are the same width. The addComponent method creates a GridBagConstraints for each Swing component.

Clicking on the “Start Game” button triggers the StartGameActionListener.

We’ll look at the StartGameActionListener class now.

package com.ggl.game2048.controller;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

import com.ggl.game2048.model.Game2048Model;
import com.ggl.game2048.view.Game2048Frame;

public class StartGameActionListener implements ActionListener {
	
	private Game2048Frame frame;
	
	private Game2048Model model;
	
	public StartGameActionListener(Game2048Frame frame, 
			Game2048Model model) {
		this.frame = frame;
		this.model = model;
	}

	@Override
	public void actionPerformed(ActionEvent event) {
		model.setHighScores();
		model.initializeGrid();
		model.setArrowActive(true);
		model.addNewCell();
		model.addNewCell();
		
		frame.repaintGridPanel();
		frame.updateScorePanel();
	}

}

Most of the code resides in the model and view classes. The actionPerformed method performs the necessary model and view methods to start the game. Since this code is executed quickly, we perform it inline. If the code took a long time to execute, we wouldn’t want to tie up the EDT. We execute long running code in a separate thread.

Next, we’ll look at the other action classes. These classes are triggered by one of the arrow keys.

We’ll look at the UpArrowAction class.

package com.ggl.game2048.controller;

import java.awt.event.ActionEvent;

import javax.swing.AbstractAction;

import com.ggl.game2048.model.Game2048Model;
import com.ggl.game2048.view.Game2048Frame;

public class UpArrowAction extends AbstractAction {

	private static final long serialVersionUID = -2851527479086591525L;
	
	private Game2048Frame frame;
	
	private Game2048Model model;

	public UpArrowAction(Game2048Frame frame, Game2048Model model) {
		this.frame = frame;
		this.model = model;
	}

	@Override
	public void actionPerformed(ActionEvent event) {		
		if (model.isArrowActive()) {
			if (model.moveCellsUp()) {
				if (model.isGameOver()) {
					model.setArrowActive(false);
				} else {
					addNewCell();
				}
			}
		}
	}

	private void addNewCell() {
		model.addNewCell();
		
		frame.repaintGridPanel();
		frame.updateScorePanel();
	}



}

The actionPerformed method moves the cells up. If any cells move, then the game over test is performed. If the game is over, the arrow keys are logically disabled. The arrow keys still trigger the actions, but since the actions check if the arrow keys are enabled, nothing happens.

If any cells move, and the game is not over, a new 2 or 4 value cell is placed in a random location on the grid. The view is updated.

Since this actionPerformed code is executed quickly, we perform it inline. If the code took a long time to execute, we wouldn’t want to tie up the EDT. We execute long running code in a separate thread.

The LeftArrowAction, DownArrowAction, and RightArrowAction classes are very similar. The only difference is which move method in the model is performed. They are presented now without additional comments.

package com.ggl.game2048.controller;

import java.awt.event.ActionEvent;

import javax.swing.AbstractAction;

import com.ggl.game2048.model.Game2048Model;
import com.ggl.game2048.view.Game2048Frame;

public class LeftArrowAction extends AbstractAction {

	private static final long serialVersionUID = 863330348471372562L;

	private Game2048Frame frame;
	
	private Game2048Model model;

	public LeftArrowAction(Game2048Frame frame, Game2048Model model) {
		this.frame = frame;
		this.model = model;
	}
	
	@Override
	public void actionPerformed(ActionEvent e) {
		if (model.isArrowActive()) {
			if (model.moveCellsLeft()) {
				if (model.isGameOver()) {
					model.setArrowActive(false);
				} else {
					model.addNewCell();
					
					frame.repaintGridPanel();
					frame.updateScorePanel();
				}
			}
		}
	}

}
package com.ggl.game2048.controller;

import java.awt.event.ActionEvent;

import javax.swing.AbstractAction;

import com.ggl.game2048.model.Game2048Model;
import com.ggl.game2048.view.Game2048Frame;

public class DownArrowAction extends AbstractAction {

	private static final long serialVersionUID = 7347478777733160296L;

	private Game2048Frame frame;
	
	private Game2048Model model;

	public DownArrowAction(Game2048Frame frame, Game2048Model model) {
		this.frame = frame;
		this.model = model;
	}
	
	@Override
	public void actionPerformed(ActionEvent event) {	
		if (model.isArrowActive()) {
			if (model.moveCellsDown()) {
				if (model.isGameOver()) {
					model.setArrowActive(false);
				} else {
					model.addNewCell();
				
					frame.repaintGridPanel();
					frame.updateScorePanel();
				}
			}
		}
	}

}
package com.ggl.game2048.controller;

import java.awt.event.ActionEvent;

import javax.swing.AbstractAction;

import com.ggl.game2048.model.Game2048Model;
import com.ggl.game2048.view.Game2048Frame;

public class RightArrowAction extends AbstractAction {

	private static final long serialVersionUID = 2982995823948983992L;

	private Game2048Frame frame;
	
	private Game2048Model model;

	public RightArrowAction(Game2048Frame frame, Game2048Model model) {
		this.frame = frame;
		this.model = model;
	}

	@Override
	public void actionPerformed(ActionEvent e) {
		if (model.isArrowActive()) {
			if (model.moveCellsRight()) {
				if (model.isGameOver()) {
					model.setArrowActive(false);
				} else {
					model.addNewCell();
					
					frame.repaintGridPanel();
					frame.updateScorePanel();
				}
			}
		}
	}

}

Finally, we have the HighScoreProperties class. I put the high score in a properties file so that I could save it. Yes, I could go into the properties file and change the high score manually. If you’re writing a game for other people to play, you’ll need to obfuscate the values so they cant be easily changed. One way to do this would be to add a check digit to the values.

package com.ggl.game2048.properties;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Properties;

import com.ggl.game2048.model.Game2048Model;

public class HighScoreProperties {
	
	private static final String fileName =
			"game2048.properties";
	
	private static final String highCell  = "highCell";
	private static final String highScore = "highScore";
	
	private Game2048Model model;

	public HighScoreProperties(Game2048Model model) {
		this.model = model;
	}

	public void loadProperties() {
		Properties properties = new Properties();
		
		InputStream is = null;
		File file = new File(fileName);
		try {
			is = new FileInputStream(file);
			properties.load(is);
			model.setHighScore(Integer.parseInt(
					properties.getProperty(highScore)));
			model.setHighCell(Integer.parseInt(
					properties.getProperty(highCell)));
		} catch (FileNotFoundException e) {
			
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
	
	public void saveProperties() {
		Properties properties = new Properties();
		properties.setProperty(highScore, 
				Integer.toString(model.getHighScore()));
		properties.setProperty(highCell, 
				Integer.toString(model.getHighCell()));
		
		OutputStream os = null;
		File file = new File(fileName);
		
		try {
			os = new FileOutputStream(file);
			properties.store(os, "2048 High Score");
			os.flush();
		} catch (FileNotFoundException e) {
			e.printStackTrace();
		} catch (IOException e) {
			e.printStackTrace();
		}
		
		try {
			if (os != null) {
				os.close();
			}
		} catch (IOException e) {
			e.printStackTrace();
		}
		
	}
}

I wrote the properties file to the same directory as my Java project. You should change the file name to point to a user directory.

And here’s the properties file.

#2048 High Score
#Mon Mar 17 10:26:06 MDT 2014
highScore=3260
highCell=256

Thank you for reading this article to the end. I hope it was helpful in showing how to put together a moderately complex Java Swing application.

17 Comments

  • Posted Monday, 10 November 2014 at 4:48 am | Permalink

    Hi, I wanted to know if I could use this for an AI project I’ve been working on, wanted to put it on GitHub when it’s finished, will link original source back to you, would this be okay? Thanks.

  • Jitendra Malviya
    Posted Saturday, 18 April 2015 at 9:56 am | Permalink

    Thank, It’s working well.

  • Goppe Sascha
    Posted Wednesday, 6 May 2015 at 3:42 am | Permalink

    Really nice

  • Duong
    Posted Sunday, 20 September 2015 at 8:23 am | Permalink

    Hello, I want download source, please send to truongtungduong9x@gmail.com
    Thank you !

  • suat
    Posted Tuesday, 8 March 2016 at 6:46 am | Permalink

    Can i take source code as a project ? suat1974@hotmail.com my e-mail

    • Gilbert Le Blanc
      Posted Wednesday, 9 March 2016 at 3:54 pm | Permalink

      Yes, you can use my source code.

  • Ken
    Posted Sunday, 20 March 2016 at 9:03 am | Permalink

    Hi
    I want to download source.
    can you send the project to me?
    hing50178@gmail.com

    Thank you

  • Naomi
    Posted Monday, 4 April 2016 at 10:36 pm | Permalink

    what is the usage of setHighScores() method and what is the difference between this method and setHighScore() method

    • Gilbert Le Blanc
      Posted Wednesday, 6 April 2016 at 9:24 am | Permalink

      The setHighScore method sets the highScore field only.

      The setHighScores method sets the highScore and highCell fields, as well as resetting the currentScore and currentCell fields to zero.

      The first method is just a setter, while the second method performs the function of setting the high score.

      • Naomi
        Posted Wednesday, 6 April 2016 at 6:55 pm | Permalink

        Hello, I have another confused two methods, moveCellsUp() and moveCellsUpLoop(), I cannot understand these two methods, and also the variables like dirty, columnDirty and cellDirty. What are they represent?

  • prasad yeole
    Posted Friday, 26 August 2016 at 1:10 pm | Permalink

    it’s really good .
    indirectally you help me for my college project
    thank you …..

  • namrata
    Posted Wednesday, 18 January 2017 at 10:15 am | Permalink

    Can any1 help plzz I want to add help button which will display instructions of game

    • Gilbert Le Blanc
      Posted Wednesday, 18 January 2017 at 11:25 am | Permalink

      Create a JDialog that contains a JTextArea. Put the instructions in the JTextArea. Use an action listener to display the JDialog when the Help button is pressed.

      • namrata
        Posted Sunday, 22 January 2017 at 6:49 am | Permalink

        sir can you please send the code because i tried a lot i am not getting it

        • namrata
          Posted Sunday, 22 January 2017 at 6:57 am | Permalink

          please reply as soon as possibe.

        • namrata
          Posted Sunday, 22 January 2017 at 6:58 am | Permalink

          please reply as soon as possible.

  • Namrata
    Posted Sunday, 22 January 2017 at 7:05 am | Permalink

    I want to add pause button on screen which will pause game. plzzz help mi

Post a Comment

Your email is kept private. Required fields are marked *