Programmatic GUI

Whilst we can add items to the UI in the designer manually, sometimes it may be impractical (for example if we want to add a significant number of items to the UI), or we need to alter the UI dynamically

This tutorial will work through the process of creating a game of minesweeper

  1. Starting with an empty IntelliJ project, add a Swing form named MinesweeperUI with a bound class (use the start of the Swing GUI tutorial if you need a reminder how to do this)
  2. Setup the a UI as shown in the image below, the grid layout should be three units wide and two units height. The bottom should contain a JPanel spanning the full width. Swing UI with label, spacer, button and below that a JPanel This JPanel will eventually contain a grid of buttons which will represent the possible locations of mines in the mine field.
  3. Next, determine the size of the grid, add instance variables as follows:
    private final int width = 7;
    private final int height = 5;
  4. Set the field name for the JPanel you've added to minePanel, and check the 'Custom Create' option
  5. In order to display items in the JPanel programmatically, we need to determine what type of layout we will use to determine how the components we add will be displayed. In the createUIComponants() method add the following code to create the layout, instantiate the JPanel and set the layout for the JPanel:
    GridLayout mineLayout = new GridLayout(height,width);
    minePanel = new JPanel();
    minePanel.setLayout(mineLayout);
  6. We will eventually populate the JPanel with buttons, and we need to be able to handle interaction with each button, so the next stage is to create an ActionListener for that. Add an instance variable to the class as follows:
    private ActionListener mineButtonListener;
  7. Next add a method to instantiate the listener as follows:
    void setupMineButtonListener(){
        mineButtonListener = new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                System.out.println("Button " + e.getActionCommand() + " pressed");
                // TODO: determine position of mine in grid and handle     
            }
        };
    }
  8. Then inside the createUIComponents() method add the following code to instantiate the button listener:
    //mine button listener must be created before the buttons (can't go in constructor)
    setupMineButtonListener();
  9. Also within the createUIComponents() method and add the following code which will add the same number of buttons as there will be in the grid. Note that each button will have its 'ActionCommand' value set with a string corresponding to the number of the button (from 0 to the number in the grid). This will enable us to identify it at a later point. We will also associate each button with the ActionListener we created earlier:
    for (int i = 0; i < (width * height); i++) {
        JButton mineButton = new JButton(" ");
        mineButton.addActionListener(mineButtonListener);
        mineButton.setActionCommand(String.valueOf(i));
        minePanel.add(mineButton);
    }
  10. Run the application and check that it appears as shown below: Completed Java Swing UI for a minesweeper game
  11. Click on a selection of buttons and ensure that the console shows the number of the button being pressed (starting at 0 going up to 34)

The Minesweeper game classes

We will return to the MinesweeperUI class later, but the next stage is to build the logic for the Minesweeper game itself. We will create three class for this, the first of which will simply be to represent a point in 2D space.

  1. Add a class named Point as shown here:
    class Point {
        
        private int x, y;
    
        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
        int getX() {
            return x;
        }
        int getY() {
            return y;
        }
    }
  2. As part of the logic for the game we will need to be able to compare instances of Point. To do this we can override Object's equals method (which allows one object to be compared to a another). As we will want to compare an instance Point to another instance of Point (and not an Object), we can test if the other object being compared is also a Point and adjust the behaviour accordingly. We test if an object is a point using the keyword instanceof. Add the following code inside the Point class:
    @Override
    public boolean equals(Object obj) {
        if (obj instanceof Point){
            Point otherPoint = (Point)obj;
            return this.x == otherPoint.x && this.y == otherPoint.y ;
        }
        return false;
    }
  3. Next create a class named MineInfo. This class will store two pieces of information relating to a possible mine location - whether it is a mine, and how many mines are adjacent to the location. The code for this class should be as follows:
    public class MineInfo {
        private boolean isMine = false;
        private int adjacentMineCount = 0;
        
        public boolean isMine() {
            return isMine;
        }
        public void setMine(boolean mine) {
            isMine = mine;
        }
        public int getAdjacentMineCount() {
            return adjacentMineCount;
        }
        public void setAdjacentMineCount(int adjacentMineCount) {
            this.adjacentMineCount = adjacentMineCount;
        }
    }
  4. The final class to create will represent the Mine field itself, so add a new class called MineField
  5. Add the following instance variables to the class:
    private boolean started = false;
    private int mineCount;
    private int width, height;
    private MineInfo[][] field;
  6. Create a getter for the mineCount variable
  7. Create a constructor as follows, which will instantiate a Minefield with dimensions provided, with each cell holding an instance of MineInfo:
    MineField(int width, int height) {
        field = new MineInfo[height][width];
        this.width = width;
        this.height = height;
        
        mineCount = (width * height)/5;
        for (int y = 0; y < height; y++) {
            for (int x = 0; x < width; x++) {
                field[y][x] = new MineInfo();
            }
        }
    }
    You may wonder why at this stage none of the instances of MineInfo have any mines - this is because we want to avoid the possibility of a player starting on a mine - so we will populate the mine field once they have chosen their starting location (and avoid placing a mine in that location)
  8. When the game starts, mines will need to be placed in a number of random locations. Add code as follows which can generate a random point within the constrains of the grid:
    private Point getRandomPoint() {
        Random rand = new Random();
        int x = rand.nextInt(width);
        int y = rand.nextInt(height);
        return new Point(x, y);
    }
  9. The next stage is to populate the mines (avoiding the player start location). Add the following code which will do that:
    private void populateMinesAvoidingPoint(int count, Point startPoint) {
        ArrayList<Point> mineLocations = new ArrayList<Point>();
        do {
            final Point randPoint = getRandomPoint();
            if (!startPoint.equals(randPoint) && !mineLocations.contains(randPoint)) {
                mineLocations.add(randPoint);
            }
        } while (mineLocations.size() < count);
        for (Point point : mineLocations) {
            field[point.getY()][point.getX()].setMine(true);
        }
    }
  10. We will also need to be able to check each of any given cell's neighbours to provide a count of mines adjacent to a cell (to display to the user). First add a method as follows which can provide all the valid neighbours to any given point:
    private Set<Point> validNeighboursForPoint(Point point) {
        Set<Point> neighbours = new HashSet<Point>();
        final int minX = Math.max(0, point.getX() - 1);
        final int maxX = Math.min(width - 1, point.getX() + 1);
        final int minY = Math.max(0, point.getY() - 1);
        final int maxY = Math.min(height - 1, point.getY() + 1);
        for (int y = minY; y <= maxY; y++) {
            for (int x = minX; x <= maxX; x++) {
                neighbours.add(new Point(x, y));
            }
        }
        return neighbours;
    }
  11. Then add a method which can count the total number mines in an array of Points as follows:
    private int countMinesAtPoints(Set<Point> points) {
        int count = 0;
        for (Point point : points) {
            if (field[point.getY()][point.getX()].isMine()) {
                count++;
            }
        }
        return count;
    }
  12. Finally add a method which can use the two methods previously added, to populate the adjacent mine count of each MineInfo object in the mine field:
    private void populateAdjacentMineCount() {
        for (int y = 0; y < field.length; y++) {
            for (int x = 0; x < field[y].length; x++) {
                final Set<Point> neighbours = validNeighboursForPoint(new Point(x, y));
                field[y][x].setAdjacentMineCount(countMinesAtPoints(neighbours));
            }
        }
    }
  13. As already discussed, the Minefield won't be populated until the player starts - they will do this by checking a mine's location (by clicking on a button in the UI). Add the following method which will do that:
    MineInfo checkMine(Point point) {
        if (!started) {
            started = true;
            populateMinesAvoidingPoint(mineCount, point);
            populateAdjacentMineCount();
        }
        MineInfo location = field[point.getY()][point.getX()];
        MineInfo mineInfo = new MineInfo();
        mineInfo.setAdjacentMineCount(location.getAdjacentMineCount());
        mineInfo.setMine(location.isMine());
        return mineInfo;
    }

Connecting it all up

  1. Return to the MineSweeperUI class.
  2. Declare a variable named mineField and instantiate it with a new instance of MineField, passing the width and height to the constructor
  3. We will need to be able to take the id from a button (the action command) and get a Point for it. Add the following method which will do that:
    private Point pointForId(int mineId)
    {
        int x = mineId % width;
        int y = mineId / width;
        return new Point(x, y);
    }
  4. If the player loses the game, by clicking on a mine, then all the mines need to be displyed (the idea being that this sets off a chain reaction). Add the following method which will iterate through all the JButtons and find the corresponding MineInfo element in the MineField, and change the button text to an asterisk if it represents a mine:
    void explodeAllMines(){
        for (Component component:minePanel.getComponents()){
            if (component instanceof JButton) {
                JButton button = (JButton) component;
                int mineId = Integer.parseInt(button.getActionCommand());
                Point point = pointForId(mineId);
                MineInfo info = mineField.checkMine(point);
                if (info.isMine()) {
                    button.setText("*");
                }
            }
        }
    }
  5. Next, find the TODO: determine position of mine in grid and handle statement in the setupMineButtonListener() method and add the following code:
    int mineId = Integer.parseInt(e.getActionCommand());
    Point point = pointForId(mineId);
    MineInfo info = mineField.checkMine(point);
    JButton button = (JButton)e.getSource();
    if (info.isMine()){
        explodeAllMines();
    }else {
        button.setText(String.valueOf(info.getAdjacentMineCount()));
    }
  6. The game should be playable to a limited extent, but it cannot be reset if the game ends. Add the following method which will reset the labels:
    void resetButtons(){
        for (Component possibleButton:minePanel.getComponents()) {
            if (possibleButton instanceof JButton){
                JButton actualButton = (JButton)possibleButton;
                actualButton.setText(" ");
            }
        }
    }
  7. Add an action listener to the reset button and ensure that it both calls the above method, and sets the mineField variable to a new instance of MineField. This can be done in the constructor

Further tasks

  1. Find out how to add a listener for a right click and use it so the player can flag cells as holding a mine
  2. Add code so the Mines Remaining label shows the number of mines that are left (based on the number in the MineField and the number of flagged cells)
  3. Change the colour of the mine count labels so 1 is blue, 2 is green. 3 is red and 4 and above are black
  4. Modify the game so that cells adjacent to ones with a count of 0 are automatically evaluated, such that clear areas of the minefield are automatically determined (e.g. display the value of any cells adjacent to one clicked with a value of 0 adjacent mines, and do the same for any found that also have a value of 0, etc. etc.). It might be helpful to make MineField's validNeighboursForPoint() method public to do this
  5. Add a timer that counts up

Improving the random mine generation algorithm

You may have noticed that the populateMinesAvoidingPoint method was not terribly efficient - everytime a random Point was generated that was identical to one already generated would require an additional iteration of the do-while loop.

The method could instead be replaced with the code below, which prevents that from happening. It also allows the getRandomPoint() method to be removed. The code has some comments, but read through it carefully and try to fully understand what is going on:

private void populateMinesAvoidingPoint(int noOfMines, Point startPoint) {
    ArrayList<Integer> minesIndexList = new ArrayList<Integer>();
    //populate list of integers corresponding to all positions in 2D array except start point
    for (int i = 0; i < (width * height); i++) {
        int startMineIndex = startPoint.getX() + (startPoint.getY() * width);
        if (i != startMineIndex) {
            minesIndexList.add(i);
        }
    }

    Random rand = new Random();
    while (noOfMines > 0){
        int randomPosition = rand.nextInt(minesIndexList.size());
        int mineIndex = minesIndexList.get(randomPosition);
        
        //get position in minefield corresponding to index 
        int x = mineIndex % width;
        int y = mineIndex / width;
        field[y][x].setMine(true);
        
        //remove position from list so it can't be selected again
        minesIndexList.remove(randomPosition);
        noOfMines--;
    }
}