Musings about Java and Swing

Kakurasu Using Java Swing

In mid-January, I read a Stack Overflow question about the game Kakurasu. The game looked interesting, so I decided to write a Java Swing version of the game.

Introduction

Here’s a picture of the Swing GUI I created, with a 4 x 4 grid. My window cropping tool does not adjust for the smaller borders of a Windows 10 window. I used Java 7 for the code.

Kakurasu Game

Each of the gray tiles in the grid have two values. The across values (row values) are signified by the numbers across the top of the grid. The down values (column values) are signified by the numbers down the left side of the grid. The object of the game is to have the columns add up to the sums on the bottom, and the rows add up to the sums on the right.

This is easier to see in pictures. Here’s a picture of the Swing GUI after one of the gray tiles has been left clicked.

Kakurasu Game

The clicked tile has a value of 3 in the row, and a value of 2 in the column. The numbers in parenthesis show the total of the tiles in the column or the row. In this case, since we only clicked one tile, the value of the column is 2, and the value of the row is 3.

Kakurasu Game

Here, we’ve completed the row and the second column. A completed row or column is marked with a green check mark.

Kakurasu Game

Here, we’ve completed a row and 2 columns.

Kakurasu Game

Here we see a completed Kakurasu. All of the rows and columns have a green check mark. The tiles have changed to green, signifying that the answer is correct.

The first field on the right side of the Swing GUI shows the elapsed time. The display shows seconds and tenths of a second, until one minute. After a minute, the display is in minutes and seconds. After an hour, the display is in hours, minutes, and seconds. This let’s you know how long it took to solve the Kakurasu puzzle.

The grid size combo box allows you to play with a grid size from 4 x 4 to a grid size of 9 x 9. The GUI size adjusts with the size of the grid.

Here’s a picture of the Swing GUI with a 9 x 9 grid.

Kakurasu Game

The show answer button shows you the answer. If you get frustrated with solving the puzzle, you can left click on the show answer button to see the solution.

Kakurasu Game

The new game button creates a new game with the same grid size. The grid size combo box creates a new game with a different size grid.

Overview

I wrote 14 classes, using the model / view / controller pattern (MVC). I also used 2 images.

Kakurasu Logo
Check Mark

The first image is the logo for the game. The second image is the green check mark.

I put the images in their own image folder, and I put the image folder on the classpath. This allows me to reference the images in Eclipse, and when I package the class code in a Java JAR.

Here’s the layout of my code and images in Eclipse.

Kakurasu Folder Structure

As you can see in the image, I wrote 4 model classes, 4 view classes, and 4 controller classes. I also wrote a Runnable class to control the animation of the elapsed time.

When I say I use the model / view / controller pattern with Java Swing, I mean:

  1. The view may read values from the model.
  2. The view may not update the model.
  3. The controller will update the model.
  4. The controller will repaint / revalidate the view.

Basically, the model is ignorant of the view and controller. This allows you to change the view and controller from Swing to a web site, or an Android app.

The model / view / controller pattern allows you to focus on one part of the Swing GUI at a time. In general, you’ll create the model first, then the view, and finally the controllers. You will have to go back and add fields to the model. I guarantee that you’ll come up with something you didn’t think of when you created the first cut of the model classes.

Now for the code. The first class is the main Kakurasu class.

package com.ggl.kakurasu;

import javax.swing.SwingUtilities;

import com.ggl.kakurasu.model.KakurasuModel;
import com.ggl.kakurasu.view.KakurasuFrame;

public class Kakurasu implements Runnable {

	public static void main(String[] args) {
		SwingUtilities.invokeLater(new Kakurasu());
	}

	@Override
	public void run() {
		KakurasuModel model = new KakurasuModel();
		new KakurasuFrame(model);
	}

}

This short class does 3 things.

  1. Puts the Swing components on the Event Dispatch thread (EDT) by executing the invokeLater method of SwingUtilities.
  2. Instantiates the KakurasuModel class.
  3. Instantiates the KakurasuFrame class.

A version of this class is how I start all of my Swing applications.

Model

Let’s look at the model classes. The first model class is the KakurasuModel class. This class holds the fields for the Kakurasu game.

package com.ggl.kakurasu.model;

import java.awt.Image;

public class KakurasuModel {

	private boolean showSolution;

	private int cellWidth;

	private int[] acrossSums;
	private int[] downSums;

	private long startTime;

	private Image checkMark;

	private KakurasuGrid grid;

	public KakurasuModel() {
		this.cellWidth = 4;
		initialize();
	}

	public void initialize() {
		this.grid = new KakurasuGrid(cellWidth);
		KakurasuAnswer answer = new KakurasuAnswer(grid);
		this.acrossSums = answer.getAcrossSums();
		this.downSums = answer.getDownSums();
		this.showSolution = false;
	}

	public int getCellWidth() {
		return cellWidth;
	}

	public void setCellWidth(int cellWidth) {
		this.cellWidth = cellWidth;
	}

	public int[] getAcrossSums() {
		return acrossSums;
	}

	public int[] getDownSums() {
		return downSums;
	}

	public KakurasuGrid getGrid() {
		return grid;
	}

	public boolean isShowSolution() {
		return showSolution;
	}

	public void setShowSolution(boolean showSolution) {
		this.showSolution = showSolution;
	}

	public Image getCheckMark() {
		return checkMark;
	}

	public void setCheckMark(Image checkMark) {
		this.checkMark = checkMark;
	}

	public void setStartTime(long startTime) {
		this.startTime = startTime;
	}

	public String getElapsedTime(long currentTime) {
		String output = "";

		long elapsedTime = currentTime - startTime;
		int centiseconds = (int) ((elapsedTime + 50L) / 100L);

		if (centiseconds < 600) {
			int seconds = centiseconds / 10;
			centiseconds = (centiseconds - (seconds * 10)) % 10;
			output = String.format("%2d.%1d", seconds, centiseconds);
		} else {
			int seconds = (centiseconds + 5) / 10;
			int minutes = seconds / 60;
			int hours = minutes / 60;
			minutes = (minutes - (hours * 60)) % 60;
			seconds = (seconds - (hours * 60 * 60)) % 60;

			if (hours <= 0) {
				output = String.format("%2d:%02d", minutes, seconds);
			} else {
				output = String
						.format("%2d:%02d:%02d", hours, minutes, seconds);
			}
		}

		return output;
	}
}

The more interesting fields are the grid, acrossSums, and downSums. The methods are getters and setters, except for the getElapsedTime method. You can see how I use the format method of the String class to format the elapsed time.

The next model class is the KakurasuGrid class.

package com.ggl.kakurasu.model;

import com.ggl.kakurasu.view.BackgroundColor;

public class KakurasuGrid {

	private int gridWidth;

	private KakurasuCell[][] cells;

	public KakurasuGrid(int gridWidth) {
		setGridWidth(gridWidth);
	}

	public int getGridWidth() {
		return gridWidth;
	}

	public void setGridWidth(int gridWidth) {
		this.gridWidth = gridWidth;
		this.cells = new KakurasuCell[gridWidth][gridWidth];
		setCells();
	}

	public KakurasuCell[][] getCells() {
		return cells;
	}

	private void setCells() {
		for (int i = 0; i < gridWidth; i++) {
			for (int j = 0; j < gridWidth; j++) {
				KakurasuCell cell = new KakurasuCell((j + 1), (i + 1),
						BackgroundColor.getFirstColor());
				cells[i][j] = cell;
			}
		}
	}

}

This class created the Kakurasu grid of tiles. The BackgroundColor class will be discussed later. The getFirstColor method of the BackgroundColor class returns the first (starting) color, which is gray.

The next model class is the KakurasuCell class.

package com.ggl.kakurasu.model;

import java.awt.Color;

public class KakurasuCell {

	private boolean isSolution;

	private final int acrossValue;
	private final int downValue;

	private Color backgroundColor;

	public KakurasuCell(int acrossValue, int downValue, Color backgroundColor) {
		this.acrossValue = acrossValue;
		this.downValue = downValue;
		this.backgroundColor = backgroundColor;
		this.isSolution = true;
	}

	public Color getBackgroundColor() {
		return backgroundColor;
	}

	public void setBackgroundColor(Color backgroundColor) {
		this.backgroundColor = backgroundColor;
	}

	public int getAcrossValue() {
		return acrossValue;
	}

	public int getDownValue() {
		return downValue;
	}

	public boolean isSolution() {
		return isSolution;
	}

	public void setSolution(boolean isSolution) {
		this.isSolution = isSolution;
	}

}

The isSolution field in this class is true if the tile is part of the solution, and false if the tile is not part of the solution. The rest of the fields in this class are self-explanatory.

The last model class is the KakurasuAnswer class.

package com.ggl.kakurasu.model;

import java.util.Random;

public class KakurasuAnswer {

	private int[] acrossSums;
	private int[] downSums;

	private Random random;

	public KakurasuAnswer(KakurasuGrid grid) {
		this.random = new Random();
		calculateRandomSolution(grid);
		calculateAnswers(grid);
	}

	private void calculateRandomSolution(KakurasuGrid grid) {
		int total = grid.getGridWidth() * grid.getGridWidth();
		int minimum = total / 4;
		int range = total / 2;
		int count = random.nextInt(range) + minimum;

		for (int i = 0; i < count; i++) {
			int x = random.nextInt(grid.getGridWidth());
			int y = random.nextInt(grid.getGridWidth());
			grid.getCells()[x][y].setSolution(false);
		}
	}

	private void calculateAnswers(KakurasuGrid grid) {
		downSums = new int[grid.getGridWidth()];
		for (int i = 0; i < grid.getGridWidth(); i++) {
			int sum = 0;
			for (int j = 0; j < grid.getGridWidth(); j++) {
				if (grid.getCells()[j][i].isSolution()) {
					sum += grid.getCells()[j][i].getDownValue();
				}
			}
			downSums[i] = sum;
		}

		acrossSums = new int[grid.getGridWidth()];
		for (int i = 0; i < grid.getGridWidth(); i++) {
			int sum = 0;
			for (int j = 0; j < grid.getGridWidth(); j++) {
				if (grid.getCells()[i][j].isSolution()) {
					sum += grid.getCells()[i][j].getAcrossValue();
				}
			}
			acrossSums[i] = sum;
		}
	}

	public int[] getAcrossSums() {
		return acrossSums;
	}

	public int[] getDownSums() {
		return downSums;
	}

}

This class removes some of the tiles from the solution, and calculates the across sums and the down sums.

I had trouble coming up with a good method for removing tiles from the solution. I tried a few different methods, and finally decided to remove anywhere from 1/4 to 3/4 of the tiles at random. This works well enough, especially for the larger grid sizes.

View

Now that we’ve defined the model classes, let’s look at the view classes. The first view class is the KakurasuFrame class.

package com.ggl.kakurasu.view;

import java.awt.BorderLayout;
import java.awt.Image;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.IOException;

import javax.imageio.ImageIO;
import javax.swing.JFrame;
import javax.swing.JPanel;

import com.ggl.kakurasu.model.KakurasuModel;
import com.ggl.kakurasu.runnable.ElapsedTimeRunnable;

public class KakurasuFrame {

	private ControlPanel controlPanel;

	private DrawingPanel drawingPanel;

	private ElapsedTimeRunnable elapsedTimeRunnable;

	private JFrame frame;

	private KakurasuModel model;

	public KakurasuFrame(KakurasuModel model) {
		this.model = model;
		model.setCheckMark(readImage("/check_mark.png"));
		createPartControl();
	}

	private void createPartControl() {
		frame = new JFrame("Kakurasu");
		frame.setIconImage(readImage("/kakurasu.png"));
		frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
		frame.addWindowListener(new WindowAdapter() {
			@Override
			public void windowClosing(WindowEvent event) {
				exitProcedure();
			}
		});

		JPanel mainPanel = new JPanel();
		mainPanel.setLayout(new BorderLayout());

		drawingPanel = new DrawingPanel(this, model);
		mainPanel.add(drawingPanel, BorderLayout.CENTER);

		controlPanel = new ControlPanel(this, model);
		mainPanel.add(controlPanel.getPanel(), BorderLayout.EAST);

		frame.add(mainPanel);

		frame.pack();
		frame.setLocationByPlatform(true);

		model.setStartTime(System.currentTimeMillis());
		updateControlPanel(model.getElapsedTime(System.currentTimeMillis()));

		frame.setVisible(true);

		elapsedTimeRunnable = new ElapsedTimeRunnable(this, model);
		new Thread(elapsedTimeRunnable).start();
	}

	public void exitProcedure() {
		elapsedTimeRunnable.setRunning(false);
		frame.dispose();
		System.exit(0);
	}

	private Image readImage(String streamString) {
		try {
			return ImageIO.read(getClass().getResourceAsStream(streamString));
		} catch (IOException e) {
			e.printStackTrace();
			return null;
		}
	}

	public JFrame getFrame() {
		return frame;
	}

	public void repaintDrawingPanel() {
		drawingPanel.repaint();
	}

	public void resizeDrawingPanel() {
		drawingPanel.setPreferredSize();
	}

	public void updateControlPanel(String elapsedTime) {
		controlPanel.updatePartControl(elapsedTime);
	}

	public void startElapsedTimeRunnable() {
		elapsedTimeRunnable.setSolved(false);
	}

	public void endElapsedTimeRunnable() {
		elapsedTimeRunnable.setSolved(true);
	}
}

The readImage method reads the check mark icon and the logo.

The createPartControl method is pretty much the same for all of my Java Swing projects. I create a JFrame, and add all of the JPanels. For other projects, the JPanels are different. I have a window listener so that when the window closes, I can stop the animation thread before I exit the application.

Notice that we don’t extend JFrame. We use a JFrame. The only reason you should extend a Swing component, or any Java class, is when you want to override one or more of the methods.

The repaintDrawingPanel method and those methods following the repaintDrawingPanel method are convenience methods. There’s no reason that the controller classes need to know about the inner workings of the view. These convenience methods allow the controller classes to repaint / revalidate the view from the JFrame instance.

The next view class is the DrawingPanel class.

package com.ggl.kakurasu.view;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Rectangle;
import java.awt.font.FontRenderContext;
import java.awt.geom.Rectangle2D;

import javax.swing.JPanel;

import com.ggl.kakurasu.controller.CellMouseListener;
import com.ggl.kakurasu.model.KakurasuCell;
import com.ggl.kakurasu.model.KakurasuModel;

public class DrawingPanel extends JPanel {

	private static final long serialVersionUID = -8507499193572225103L;

	private static final int cellWidth = 50;

	private int correct;

	private KakurasuFrame frame;

	private KakurasuModel model;

	public DrawingPanel(KakurasuFrame frame, KakurasuModel model) {
		this.frame = frame;
		this.model = model;
		this.addMouseListener(new CellMouseListener(frame, model, cellWidth));
		this.setBackground(Color.WHITE);
		setPreferredSize();
	}

	public void setPreferredSize() {
		int width = model.getGrid().getGridWidth() + 3;
		int pixelWidth = width * cellWidth + 1;
		this.setPreferredSize(new Dimension(pixelWidth, pixelWidth));
	}

	@Override
	protected void paintComponent(Graphics g) {
		super.paintComponent(g);

		drawAcrossNumbers(g);
		drawDownNumbers(g);
		drawAcrossAnswers(g);
		drawDownAnswers(g);

		correct = 0;

		drawAcrossSums(g);
		drawDownSums(g);

		drawCells(g);
	}

	private void drawAcrossNumbers(Graphics g) {
		int x = cellWidth;
		int y = 0;

		Font font = getFont().deriveFont(24.0F);
		g.setColor(Color.GRAY);

		for (int i = 1; i <= model.getGrid().getGridWidth(); i++) {
			Rectangle r = new Rectangle(x, y, cellWidth, cellWidth);
			centerString(g, r, Integer.toString(i), font);
			x += cellWidth;
		}
	}

	private void drawDownNumbers(Graphics g) {
		int x = 0;
		int y = cellWidth;

		Font font = getFont().deriveFont(24.0F);
		g.setColor(Color.GRAY);

		for (int i = 1; i <= model.getGrid().getGridWidth(); i++) {
			Rectangle r = new Rectangle(x, y, cellWidth, cellWidth);
			centerString(g, r, Integer.toString(i), font);
			y += cellWidth;
		}
	}

	private void drawCells(Graphics g) {
		int x = cellWidth;
		int y = cellWidth;

		for (int i = 0; i < model.getGrid().getGridWidth(); i++) {
			for (int j = 0; j < model.getGrid().getGridWidth(); j++) {
				KakurasuCell[][] cells = model.getGrid().getCells();
				Color backgroundColor = cells[j][i].getBackgroundColor();
				Color color = backgroundColor;

				if (model.isShowSolution()) {
					if (cells[j][i].isSolution()) {
						color = BackgroundColor.getSolutionColor();
					} else {
						color = BackgroundColor.getFirstColor();
					}
				}

				if (color.equals(BackgroundColor.getSolutionColor())) {
					int possible = model.getGrid().getGridWidth();
					possible += possible;
					if (correct >= possible) {
						color = BackgroundColor.getCorrectAnswerColor();
						frame.endElapsedTimeRunnable();
					}
				}

				g.setColor(color);
				g.fillRect(x, y, cellWidth, cellWidth);
				g.setColor(Color.BLACK);

				if (backgroundColor.equals(BackgroundColor.getXColor())) {
					Rectangle r = new Rectangle(x, y, cellWidth, cellWidth);
					Font font = getFont().deriveFont(24.0F);
					centerString(g, r, "X", font);
				}

				g.drawRect(x, y, cellWidth, cellWidth);
				x += cellWidth;
			}
			x = cellWidth;
			y += cellWidth;
		}
	}

	private void drawAcrossAnswers(Graphics g) {
		int x = cellWidth;
		int y = (model.getGrid().getGridWidth() + 1) * cellWidth;

		Font font = getFont().deriveFont(24.0F);
		g.setColor(Color.BLACK);

		for (int i = 0; i < model.getGrid().getGridWidth(); i++) {
			Rectangle r = new Rectangle(x, y, cellWidth, cellWidth);
			int answer = model.getAcrossSums()[i];
			centerString(g, r, Integer.toString(answer), font);
			x += cellWidth;
		}
	}

	private void drawDownAnswers(Graphics g) {
		int x = (model.getGrid().getGridWidth() + 1) * cellWidth;
		int y = cellWidth;

		Font font = getFont().deriveFont(24.0F);
		g.setColor(Color.BLACK);

		for (int i = 0; i < model.getGrid().getGridWidth(); i++) {
			Rectangle r = new Rectangle(x, y, cellWidth, cellWidth);
			int answer = model.getDownSums()[i];
			centerString(g, r, Integer.toString(answer), font);
			y += cellWidth;
		}
	}

	private void drawAcrossSums(Graphics g) {
		int x = (model.getGrid().getGridWidth() + 2) * cellWidth;
		int y = cellWidth;

		Font font = getFont().deriveFont(18.0F);
		g.setColor(Color.BLACK);

		for (int i = 0; i < model.getGrid().getGridWidth(); i++) {
			int sum = 0;

			for (int j = 0; j < model.getGrid().getGridWidth(); j++) {
				KakurasuCell[][] cells = model.getGrid().getCells();
				if (cells[j][i].getBackgroundColor().equals(
						BackgroundColor.getSolutionColor())) {
					sum += cells[j][i].getDownValue();
				}
			}

			if (sum == model.getDownSums()[i]) {
				g.drawImage(model.getCheckMark(), x, y, this);
				correct++;
			} else if (sum > 0) {
				Rectangle r = new Rectangle(x, y, cellWidth, cellWidth);
				centerString(g, r, displaySum(sum), font);
			}

			y += cellWidth;
		}
	}

	private void drawDownSums(Graphics g) {
		int x = cellWidth;
		int y = (model.getGrid().getGridWidth() + 2) * cellWidth;

		Font font = getFont().deriveFont(18.0F);
		g.setColor(Color.BLACK);

		for (int i = 0; i < model.getGrid().getGridWidth(); i++) {
			int sum = 0;

			for (int j = 0; j < model.getGrid().getGridWidth(); j++) {
				KakurasuCell[][] cells = model.getGrid().getCells();
				if (cells[i][j].getBackgroundColor().equals(
						BackgroundColor.getSolutionColor())) {
					sum += cells[i][j].getAcrossValue();
				}
			}

			if (sum == model.getAcrossSums()[i]) {
				g.drawImage(model.getCheckMark(), x, y, this);
				correct++;
			} else if (sum > 0) {
				Rectangle r = new Rectangle(x, y, cellWidth, cellWidth);
				centerString(g, r, displaySum(sum), font);
			}

			x += cellWidth;
		}
	}

	private String displaySum(int sum) {
		return "(" + sum + ")";
	}

	/**
	 * This method centers a <code>String</code> in a bounding
	 * <code>Rectangle</code>.
	 * 
	 * @param g
	 *            - The <code>Graphics</code> instance.
	 * @param r
	 *            - The bounding <code>Rectangle</code>.
	 * @param s
	 *            - The <code>String</code> to center in the bounding rectangle.
	 * @param font
	 *            - The display font of the <code>String</code>
	 * 
	 * @see java.awt.Graphics
	 * @see java.awt.Rectangle
	 * @see java.lang.String
	 */
	private void centerString(Graphics g, Rectangle r, String s, Font font) {
		FontRenderContext frc = new FontRenderContext(null, true, true);

		Rectangle2D r2D = font.getStringBounds(s, frc);
		int rWidth = (int) Math.round(r2D.getWidth());
		int rHeight = (int) Math.round(r2D.getHeight());
		int rX = (int) Math.round(r2D.getX());
		int rY = (int) Math.round(r2D.getY());

		int a = (r.width / 2) - (rWidth / 2) - rX;
		int b = (r.height / 2) - (rHeight / 2) - rY;

		g.setFont(font);
		g.drawString(s, r.x + a, r.y + b);
	}
}

We extend the JPanel class in this case because we want to override the paintComponent method. We override the paintComponent method to draw (paint) on the JPanel. We do not use the Canvas class. Canvas is an AWT component. We do not mix AWT and Swing components unless it’s absolutely necessary. In the paintComponent method, we paint. Period. Full stop. We do nothing else in the paintComponent method. As you’ll see when we talk about the controller classes, we update the model and then and only then we call for a repaint of the drawing panel.

We set the size of the drawing panel in the constructor. A JPanel has no size by itself. The children Swing components of a JPanel give the JPanel its size. Because we’re going to draw on the drawing panel, we have to give it a preferred size.

The paintComponent method is divided up into 7 private methods. Each private method is responsible for drawing its part of the Kakurasu game.

The gridWidth (width of a grid cell in pixels) is defined in the drawing panel. The model is not concerned with fields having to do with the actual drawing. The grid drawn on the drawing panel is actually 3 cells bigger than the Kakurasu grid. One cell is for the across and down values, while the other 2 cells hold the answers and the sums. A 4 x 4 grid is actually 7 x 7, while a 9 x 9 grid is actually 12 x 12.

The next view class is the ControlPanel class.

package com.ggl.kakurasu.view;

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

import javax.swing.DefaultComboBoxModel;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;

import com.ggl.kakurasu.controller.GridSizeListener;
import com.ggl.kakurasu.controller.NewGameListener;
import com.ggl.kakurasu.controller.ShowSolutionListener;
import com.ggl.kakurasu.model.KakurasuModel;

public class ControlPanel {

	private static final Insets bottomInsets = new Insets(10, 10, 10, 10);
	private static final Insets normalInsets = new Insets(10, 10, 0, 10);

	private JPanel panel;

	private JTextField elapsedTimeField;

	private KakurasuFrame frame;

	private KakurasuModel model;

	public ControlPanel(KakurasuFrame frame, KakurasuModel model) {
		this.frame = frame;
		this.model = model;
		createPartControl();
	}

	private void createPartControl() {
		panel = new JPanel();
		panel.setLayout(new GridBagLayout());

		int gridy = 0;

		JLabel elapsedTimeLabel = new JLabel("Elapsed Time");
		elapsedTimeLabel.setHorizontalAlignment(JLabel.CENTER);
		addComponent(panel, elapsedTimeLabel, 0, gridy++, 1, 1, normalInsets,
				GridBagConstraints.CENTER, GridBagConstraints.HORIZONTAL);

		elapsedTimeField = new JTextField(6);
		elapsedTimeField.setEditable(false);
		elapsedTimeField.setFont(panel.getFont().deriveFont(24.0F));
		elapsedTimeField.setHorizontalAlignment(JTextField.RIGHT);
		addComponent(panel, elapsedTimeField, 0, gridy++, 1, 1, bottomInsets,
				GridBagConstraints.CENTER, GridBagConstraints.HORIZONTAL);

		JLabel gridSizeLabel = new JLabel("Grid Size");
		gridSizeLabel.setHorizontalAlignment(JLabel.CENTER);
		addComponent(panel, gridSizeLabel, 0, gridy++, 1, 1, normalInsets,
				GridBagConstraints.CENTER, GridBagConstraints.HORIZONTAL);

		JComboBox<GridSize> gridSizeComboBox = new JComboBox<>(
				createComboBoxModel());
		gridSizeComboBox.addItemListener(new GridSizeListener(frame, model));
		addComponent(panel, gridSizeComboBox, 0, gridy++, 1, 1, bottomInsets,
				GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL);

		JButton showAnswerButton = new JButton("Show Answer");
		showAnswerButton.addActionListener(new ShowSolutionListener(frame,
				model));
		addComponent(panel, showAnswerButton, 0, gridy++, 1, 1, normalInsets,
				GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL);

		JButton newGameButton = new JButton("New Game");
		newGameButton.addActionListener(new NewGameListener(frame, model));
		addComponent(panel, newGameButton, 0, gridy++, 1, 1, bottomInsets,
				GridBagConstraints.LINE_START, GridBagConstraints.HORIZONTAL);
	}

	private DefaultComboBoxModel<GridSize> createComboBoxModel() {
		DefaultComboBoxModel<GridSize> comboBoxModel = new DefaultComboBoxModel<>();
		comboBoxModel.addElement(new GridSize(4, "4 x 4"));
		comboBoxModel.addElement(new GridSize(5, "5 x 5"));
		comboBoxModel.addElement(new GridSize(6, "6 x 6"));
		comboBoxModel.addElement(new GridSize(7, "7 x 7"));
		comboBoxModel.addElement(new GridSize(8, "8 x 8"));
		comboBoxModel.addElement(new GridSize(9, "9 x 9"));

		return comboBoxModel;
	}

	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, 0.0D, 0.0D, anchor, fill, insets, 0, 0);
		container.add(component, gbc);
	}

	public void updatePartControl(String elapsedTime) {
		elapsedTimeField.setText(elapsedTime);
	}

	public JPanel getPanel() {
		return panel;
	}

	public class GridSize {

		private final int gridSize;

		private final String gridSizeString;

		public GridSize(int gridSize, String gridSizeString) {
			this.gridSize = gridSize;
			this.gridSizeString = gridSizeString;
		}

		public int getGridSize() {
			return gridSize;
		}

		public String getGridSizeString() {
			return gridSizeString;
		}

		@Override
		public String toString() {
			return getGridSizeString();
		}
	}

}

This is a typical Swing JPanel with Swing controls laid out vertically using the GridBagLayout.

The addComponent method creates a new GridBagConstraints for each Swing component and adds the component to the JPanel.

There’s an internal class, GridSize, used to create the DefaultComboBoxModel for the JComboBox. A GridSize instance is only used in one other place, in the GridSizeListener class, so I decided to make GridSize an internal class.

The final view class is the BackgroundColor class.

package com.ggl.kakurasu.view;

import java.awt.Color;

public class BackgroundColor {

	private static Color[] colors = { Color.GRAY, Color.BLUE, Color.WHITE };

	public static Color getFirstColor() {
		return colors[0];
	}

	public static Color getSolutionColor() {
		return colors[1];
	}

	public static Color getXColor() {
		return colors[2];
	}

	public static Color getCorrectAnswerColor() {
		return new Color(4, 206, 36);
	}

	public static Color getNextColor(Color color) {
		int index = getColorIndex(color);
		index = (index + 1) % colors.length;
		return colors[index];
	}

	public static Color getPreviousColor(Color color) {
		int index = getColorIndex(color);
		index = (index + colors.length - 1) % colors.length;
		return colors[index];
	}

	private static int getColorIndex(Color color) {
		for (int index = 0; index < colors.length; index++) {
			if (color.equals(colors[index])) {
				return index;
			}
		}

		return -1;
	}

}

This class contains nothing but static methods to control the color of the tiles. When you left click on a tile, it goes from gray to blue (part of the solution) to white (not part of the solution) to gray. When you right click on a tile it goes from gray to white to blue to gray.

By putting this behavior in its own class, I could change the tile colors by changing the colors in this class.

Controller

Let’s look at the controller classes. The first controller class is the GridSizeListener class.

package com.ggl.kakurasu.controller;

import java.awt.event.ItemEvent;
import java.awt.event.ItemListener;

import com.ggl.kakurasu.model.KakurasuModel;
import com.ggl.kakurasu.view.ControlPanel.GridSize;
import com.ggl.kakurasu.view.KakurasuFrame;

public class GridSizeListener implements ItemListener {

	private KakurasuFrame frame;

	private KakurasuModel model;

	public GridSizeListener(KakurasuFrame frame, KakurasuModel model) {
		this.frame = frame;
		this.model = model;
	}

	@Override
	public void itemStateChanged(ItemEvent event) {
		if (event.getStateChange() == ItemEvent.SELECTED) {
			GridSize gridSize = (GridSize) event.getItem();
			model.setCellWidth(gridSize.getGridSize());
			model.initialize();
			model.setStartTime(System.currentTimeMillis());

			frame.startElapsedTimeRunnable();
			frame.updateControlPanel(model.getElapsedTime(System
					.currentTimeMillis()));
			frame.getFrame().setVisible(false);
			frame.resizeDrawingPanel();
			frame.getFrame().pack();
			frame.getFrame().setVisible(true);
		}
	}

}

The itemStateChanged method is triggered when the user selects a different size grid in the JComboBox.

The KakurasuModel is updated with the new grid size. The DrawingPanel size is recalculated, and the frame is packed. The elapsed time is reset to zero and started.

The next controller class is the NewGameListener class.

package com.ggl.kakurasu.controller;

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

import com.ggl.kakurasu.model.KakurasuModel;
import com.ggl.kakurasu.view.KakurasuFrame;

public class NewGameListener implements ActionListener {

	private KakurasuFrame frame;

	private KakurasuModel model;

	public NewGameListener(KakurasuFrame frame, KakurasuModel model) {
		this.frame = frame;
		this.model = model;
	}

	@Override
	public void actionPerformed(ActionEvent event) {
		model.initialize();
		model.setStartTime(System.currentTimeMillis());
		frame.startElapsedTimeRunnable();
		frame.updateControlPanel(model.getElapsedTime(System
				.currentTimeMillis()));
		frame.repaintDrawingPanel();
	}

}

The actionPerformed method is triggered when the user clicks on the New Game button.

A new game is created in the model. The DrawingPanel is redrawn. The elapsed time is reset to zero and started.

The next controller class is the ShowSolutionListener class.

package com.ggl.kakurasu.controller;

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

import com.ggl.kakurasu.model.KakurasuModel;
import com.ggl.kakurasu.view.KakurasuFrame;

public class ShowSolutionListener implements ActionListener {

	private KakurasuFrame frame;

	private KakurasuModel model;

	public ShowSolutionListener(KakurasuFrame frame, KakurasuModel model) {
		this.frame = frame;
		this.model = model;
	}

	@Override
	public void actionPerformed(ActionEvent event) {
		model.setShowSolution(true);
		frame.endElapsedTimeRunnable();
		frame.repaintDrawingPanel();
	}

}

The show solution boolean in the model is set to true. The DrawingPanel is redrawn, the solution tiles colored blue. The elapsed time is reset to zero and restarted.

The final controller class is the CellMouseListener class.

package com.ggl.kakurasu.controller;

import java.awt.Color;
import java.awt.Point;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;

import javax.swing.SwingUtilities;

import com.ggl.kakurasu.model.KakurasuModel;
import com.ggl.kakurasu.view.BackgroundColor;
import com.ggl.kakurasu.view.KakurasuFrame;

public class CellMouseListener extends MouseAdapter {

	private int cellWidth;

	private KakurasuFrame frame;

	private KakurasuModel model;

	public CellMouseListener(KakurasuFrame frame, KakurasuModel model,
			int cellWidth) {
		this.frame = frame;
		this.model = model;
		this.cellWidth = cellWidth;
	}

	@Override
	public void mouseReleased(MouseEvent event) {
		Point p = event.getPoint();
		int x = p.x - cellWidth;
		int y = p.y - cellWidth;

		if (x < 0 || y < 0) {
			return;
		}

		x /= cellWidth;
		y /= cellWidth;

		if (x < 0 || x >= model.getCellWidth()) {
			return;
		}
		if (y < 0 || y >= model.getCellWidth()) {
			return;
		}

		Color color = model.getGrid().getCells()[x][y].getBackgroundColor();
		if (SwingUtilities.isLeftMouseButton(event)) {
			model.getGrid().getCells()[x][y].setBackgroundColor(BackgroundColor
					.getNextColor(color));
		} else if (SwingUtilities.isRightMouseButton(event)) {
			model.getGrid().getCells()[x][y].setBackgroundColor(BackgroundColor
					.getPreviousColor(color));
		}
		frame.repaintDrawingPanel();
	}

}

The DrawingPanel point clicked is converted into a grid row, column coordinate. The cell background color is retrieved, and using the BackgroundColor class, converted to the next color with a left mouse click or the previous color with a right mouse click.

The grid coordinate conversion uses the same grid cell width used when drawing the grid in the DrawingPanel.

Runnable

The final class of Kakurasu is the ElapsedTimeRunnable class.

package com.ggl.kakurasu.runnable;

import javax.swing.SwingUtilities;

import com.ggl.kakurasu.model.KakurasuModel;
import com.ggl.kakurasu.view.KakurasuFrame;

public class ElapsedTimeRunnable implements Runnable {

	private volatile boolean running;
	private volatile boolean solved;

	private KakurasuFrame frame;

	private KakurasuModel model;

	public ElapsedTimeRunnable(KakurasuFrame frame, KakurasuModel model) {
		this.frame = frame;
		this.model = model;
		this.running = true;
		this.solved = false;
	}

	@Override
	public void run() {
		while (running) {
			long sleepTime = solved ? 500L : 5L;
			while (!solved) {
				String elapsedTimeString = model.getElapsedTime(System
						.currentTimeMillis());
				updateControlPanel(elapsedTimeString);
				sleep(50L);
			}
			sleep(sleepTime);
		}
	}

	private void updateControlPanel(final String elapsedTimeString) {
		SwingUtilities.invokeLater(new Runnable() {
			@Override
			public void run() {
				frame.updateControlPanel(elapsedTimeString);
			}
		});
	}

	private void sleep(long sleepTime) {
		try {
			Thread.sleep(sleepTime);
		} catch (InterruptedException e) {

		}
	}

	public synchronized void setRunning(boolean running) {
		this.running = running;
	}

	public synchronized void setSolved(boolean solved) {
		this.solved = solved;
	}

}

The boolean fields are marked volatile so that the values can be changed from any thread. The methods that set the boolean fields are marked synchronized so that only one thread at a time can update the boolean fields.

As you can see, there are 2 booleans that are used to control the run method. The running boolean is set to false when we want to stop the Runnable completely. We start the thread containing the Runnable once. We stop the thread containing the Runnable once.

The solved boolean starts and stops the timer loop. When the solved boolean is false, the timer display is updated every 50 milliseconds, which gives an accurate display to the tenth of a second.

When the solved boolean is true, the timer stops. The outer loop still runs. We sleep for 500 milliseconds so that we’re not taking a whole lot of CPU time, but the code can respond when the timer is started again.

Post a Comment

Your email is kept private. Required fields are marked *