I'm writing Minesweeper implementation in Java. I have little experience yet, so I need some advices:
- about improving code, making it more readable and flexible
- about coding style
- about common mistakes that newbies usually do (I suspect I have some in my code)
- about code structure (for example, I'm not sure about where and how it would be better to store information about neighbour cells)
- about choosing best data structure for storing the minefield (currently I use simple 2D array, but I think it won't be suitable if I want non-square cells, for example)
- about optimization and rewriting some functions if needed
Cell.java
// represents single cell
public class Cell {
public enum Type {
MINED,EMPTY
}
public enum State {
COVERED,UNCOVERED,FLAGGED,FLAGGED_DEFUSED
}
public int nColIndex,nRowIndex; // cell position inside the grid
public int nMinesAround; // total number of mines in nearby cells
protected Type type;
protected State state;
public Cell(int row, int col) { // by default cell is clear and covered
this.nColIndex=col;
this.nRowIndex=row;
this.type = Type.EMPTY;
this.state = State.COVERED;
}
public boolean isCovered()
{
return (this.state==State.COVERED || this.state==State.FLAGGED);
}
}
Game.java
import java.util.ArrayList;
import java.util.List;
import java.util.Stack;
import java.util.concurrent.ThreadLocalRandom;
import javax.swing.JPanel;
//responsible for game logic
public class Game extends JPanel {
public enum GameStatus {
LOST,VICTORY,RUNNING;
}
protected Cell[][] minefield; // 2d array representing a minefield
protected int nCols, nRows, nMines, nUncoveredClearCells=0;
protected GameStatus status; // state of the current game (running, lost or won)
public Game(int cols, int rows, int mines)
{
// start a new game
this.nCols=cols;
this.nRows=rows;
this.nMines=mines;
this.status=GameStatus.RUNNING;
field_init();
}
protected void field_init()
{
// initialize an array by filling it with cell objects
this.minefield = new Cell[nRows][nCols];
for (int i = 0; i < nRows; i++)
{
for (int j = 0; j < nCols; j++)
{
minefield[i][j] = new Cell(i, j);
}
}
}
public void plantMines(Cell firstClickedCell)
{
// randomly plant mines by generating random coords in 2d array
// (like if it was one-dimensional)
int nMinesPlanted=0;
int nPos=0,nColIndex=0,nRowIndex=0;
Cell currentCell;
ThreadLocalRandom rnd = ThreadLocalRandom.current();
List<Cell> nearbyCells;
while(nMinesPlanted<nMines)
{
nPos = rnd.nextInt(0, nCols*nRows);
nRowIndex = nPos/nCols; // get Y cell coord (row index)
nColIndex = nPos%nCols; // get X cell coord (column index)
currentCell=minefield[nRowIndex][nColIndex];
if(currentCell.type!=Cell.Type.MINED && currentCell!=firstClickedCell)
{
currentCell.type=Cell.Type.MINED;
nearbyCells = getCellNeighbours(currentCell);
for(Cell nearbyCell : nearbyCells) nearbyCell.nMinesAround++; // notify cell neighbours about new mine
nMinesPlanted++;
}
}
}
protected List<Cell> getCellNeighbours(Cell myCell)
{
// find neighbours of current cell
// and add them to the list
List<Cell> cells = new ArrayList<Cell>();
if(myCell.nColIndex-1>=0) cells.add(minefield[myCell.nRowIndex][myCell.nColIndex-1]); // left neighbour
if(myCell.nColIndex+1<nCols) cells.add(minefield[myCell.nRowIndex][myCell.nColIndex+1]); //right neighbour
if(myCell.nRowIndex-1>=0) cells.add(minefield[myCell.nRowIndex-1][myCell.nColIndex]); // top neighbour
if(myCell.nRowIndex+1<nRows) cells.add(minefield[myCell.nRowIndex+1][myCell.nColIndex]); // bottom neighbour
if(myCell.nRowIndex+1<nRows && myCell.nColIndex+1<nCols) // bottom-right neighbour
cells.add(minefield[myCell.nRowIndex+1][myCell.nColIndex+1]);
if(myCell.nRowIndex-1>=0 && myCell.nColIndex-1>=0) // top-left neighbour
cells.add(minefield[myCell.nRowIndex-1][myCell.nColIndex-1]);
if(myCell.nRowIndex+1<nRows && myCell.nColIndex-1>=0) // bottom-left neighbour
cells.add(minefield[myCell.nRowIndex+1][myCell.nColIndex-1]);
if(myCell.nRowIndex-1>=0 && myCell.nColIndex+1<nCols) // top-right neighbour
cells.add(minefield[myCell.nRowIndex-1][myCell.nColIndex+1]);
return cells;
}
public void clickCell(Cell selectedCell)
{
// Trying to open a mined cell - the game is lost
if(selectedCell.type==Cell.Type.MINED) {
status=GameStatus.LOST;
uncoverAllMines();
}
else
{
// Clicked cell is clear
uncoverCells(selectedCell);
if(isVictory()) { // Check if there are any covered cells without mines
status=GameStatus.VICTORY; // there aren't))
flagAllMines();
}
}
}
protected void uncoverCells(Cell selectedCell)
{
// Uncover a clear cell. If it has no mines around
// we also open all its nearby cells
// Then do the same for their neighbours and so on...
Stack<Cell> stack = new Stack<Cell>();
List<Cell> nearbyCells;
stack.push(selectedCell);
while(!stack.isEmpty()) // expand area around clicked cell while possible
{
Cell currentCell = stack.pop();
if(currentCell.state!=Cell.State.UNCOVERED) // skip already uncovered cells
{
currentCell.state=Cell.State.UNCOVERED;
nUncoveredClearCells++;
if(currentCell.nMinesAround==0) // if ALL neighbours are clear, we open them too
{
nearbyCells = getCellNeighbours(currentCell);
for(Cell nearbyCell : nearbyCells)
{
if(nearbyCell.isCovered()) { // skip already uncovered cells (x2)
stack.push(nearbyCell);
}
}
}
}
}
}
protected void uncoverAllMines()
{
// If we open a mined cell, the game is lost
// In such situation other mined cells are opened automatically
// If some of them were previously flagged then they are shown as defused
Cell currentCell;
for (int i = 0; i < nRows; i++)
for (int j = 0; j < nCols; j++)
{
currentCell=minefield[i][j];
if(currentCell.type==Cell.Type.MINED) {
if(currentCell.state==Cell.State.FLAGGED) currentCell.state=Cell.State.FLAGGED_DEFUSED;
else currentCell.state=Cell.State.UNCOVERED;
}
}
}
protected boolean isVictory()
{
// Check if we haven't opened all the clear cells yet
// If it's already done - it's a victory. Otherwise we continue the game
return (nCols*nRows-nMines==nUncoveredClearCells);
}
protected void flagAllMines()
{
// If there are no clear covered cells left then the game is won
// Mined cells are automatically marked with flags
Cell currentCell;
for (int i = 0; i < nRows; i++)
for (int j = 0; j < nCols; j++)
{
currentCell=minefield[i][j];
if(currentCell.type==Cell.Type.MINED) {
currentCell.state=Cell.State.FLAGGED;
}
}
}
public void restartGame()
{
// If user wants to restart existing game
// we just cover any open cells
Cell currentCell;
nUncoveredClearCells=0;
status=GameStatus.RUNNING;
for (int i = 0; i < nRows; i++)
for (int j = 0; j < nCols; j++)
{
currentCell=minefield[i][j];
currentCell.state=Cell.State.COVERED;
}
}
public void setFlag(Cell selectedCell)
{
// Sets a flag on covered cell (or removes it if it's already present)
if(selectedCell.state==Cell.State.COVERED) selectedCell.state=Cell.State.FLAGGED;
else if(selectedCell.state==Cell.State.FLAGGED) selectedCell.state=Cell.State.COVERED;
}
}
GameGrid.java
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.io.IOException;
import java.util.HashMap;
import javax.imageio.ImageIO;
// Responsible for painting the minefield
public class GameGrid extends Game {
final static int CELL_WIDTH=20; // cell width in pixels
final static int CELL_HEIGHT=20; // cell height in pixels
HashMap<String,Image> icons;
public void paint(Graphics graphics) {
for (int i = 0; i < nRows; i++)
for (int j = 0; j < nCols; j++)
{
Image currentCellImage=null;
switch(minefield[i][j].state)
{
case COVERED: { // cell is covered
currentCellImage = icons.get("covered");
break;
}
case UNCOVERED: {
if(minefield[i][j].type==Cell.Type.MINED)
{
currentCellImage = icons.get("mined"); // cell is open and contains a mine
}
else currentCellImage = icons.get(Integer.toString(minefield[i][j].nMinesAround)); // cell is open and clear
break;
}
case FLAGGED: { // cell is covered and flagged
currentCellImage = icons.get("covered_flagged");
break;
}
case FLAGGED_DEFUSED: { // cell is open, mined and was previously marked with a flag
currentCellImage = icons.get("mined_flagged");
break;
}
default: break;
}
if(currentCellImage!=null)
graphics.drawImage(currentCellImage, j*CELL_WIDTH, i*CELL_HEIGHT, CELL_WIDTH, CELL_HEIGHT,null); // draw current cell
}
}
@Override
public Dimension getPreferredSize()
{
// report grid dimensions in order to adjust parent's size
return new Dimension(CELL_WIDTH*nCols, CELL_HEIGHT*nRows);
};
public GameGrid(int cols, int rows, int mines) {
super(cols, rows, mines);
icons_init();
}
private void icons_init()
{
// load icons that represent different cell types and states
icons = new HashMap<String,Image>();
try {
for(int i=0; i<=8; i++)
{
// Current cell is open and has N (0-8) mines around (horiz., vert. and diag.)
icons.put(Integer.toString(i), ImageIO.read(GameGrid.class.getResource("images/"+Integer.toString(i)+".png")));
}
icons.put("covered", ImageIO.read(GameGrid.class.getResource("images/coveredCell.png"))); // cell is covered (and has no flag)
icons.put("covered_flagged", ImageIO.read(MainWindow.class.getResource("images/coveredFlaggedCell.png"))); // cell is covered and has a flag
icons.put("mined", ImageIO.read(GameGrid.class.getResource("images/minedCell.png"))); // cell is open and mined
icons.put("mined_flagged", ImageIO.read(GameGrid.class.getResource("images/minedFlaggedCell.png"))); // cell is open, mined and was previously marked with a flag
}
catch(IOException ex)
{
ex.printStackTrace(); // could not load icon(s) for some reason
}
}
public Cell getCellFromCoords(int x, int y) {
return minefield[y/CELL_HEIGHT][x/CELL_WIDTH]; // get cell from it's coords
}
}
GamePanel.java
import java.awt.Graphics;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import javax.swing.Timer;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
public class GamePanel extends JPanel
{
final int ONE_SECOND=1000; // 1 s = 1000 ms
JPanel pnlProgress;
JLabel lblTime,lblMines; // elapsed time in seconds and mines count
JButton btnRestart; // "Restart" button
GameGrid myGame;
ActionListener updateCurrentTime = new ActionListener() {
public void actionPerformed(ActionEvent evt) {
int nCurrentTime=Integer.parseInt(lblTime.getText());
if(nCurrentTime>=999) { tmGame.stop(); return; }
lblTime.setText(String.format("%03d", nCurrentTime+1)); // increase time counter by one
}
};
Timer tmGame = new Timer(ONE_SECOND, updateCurrentTime); // game timer
boolean bFirstClick = true; // mines are planted after first click
MouseAdapter userClickHandler = new MouseAdapter() {
public void mousePressed(MouseEvent me) {
int x = me.getX();
int y = me.getY();
Cell requestedCell = myGame.getCellFromCoords(x,y); // get cell under mouse cursor
if(SwingUtilities.isLeftMouseButton(me) && requestedCell.state==Cell.State.COVERED) // open cell on left-click
{
if(bFirstClick) { // first click is always safe (impossible to lose)
myGame.plantMines(requestedCell);
tmGame.start();
bFirstClick=false;
}
myGame.clickCell(requestedCell);
if(myGame.status!=Game.GameStatus.RUNNING) endGame(myGame.status); // check game result
}
else if(SwingUtilities.isRightMouseButton(me)) myGame.setFlag(requestedCell); // set or remove flag on right-click
invalidate();
repaint();
}
};
public void startGame(Level lvl, boolean bRestartExisting)
{
tmGame.stop();
if(bRestartExisting) {
myGame.restartGame();
tmGame.start();
}
else {
if(myGame!=null) remove(myGame);
myGame=new GameGrid(lvl.nCols, lvl.nRows, lvl.nTotalMines);
add(myGame);
bFirstClick=true;
}
btnRestart.setText("Restart");
if(myGame.getMouseListeners().length==0) myGame.addMouseListener(userClickHandler); // enable mouse input if it's disabled
lblMines.setText(String.format("%03d", myGame.nMines));
lblTime.setText("000");
myGame.invalidate();
myGame.repaint();
}
public void endGame(Game.GameStatus state)
{
tmGame.stop();
if(state==Game.GameStatus.VICTORY) btnRestart.setText("You Win!");
else btnRestart.setText("You Lose!");
myGame.removeMouseListener(userClickHandler); // disable mouse input
}
public GamePanel()
{
this.setLayout(new BoxLayout(this,BoxLayout.Y_AXIS));
lblTime=new JLabel("000");
lblMines=new JLabel("000");
btnRestart=new JButton("Restart");
pnlProgress=new JPanel();
pnlProgress.setLayout(new BoxLayout(pnlProgress, BoxLayout.X_AXIS));
pnlProgress.add(lblTime);
pnlProgress.add(Box.createGlue());
pnlProgress.add(btnRestart);
btnRestart.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
startGame(null, true); // restart existing game
}
});
pnlProgress.add(Box.createGlue());
pnlProgress.add(lblMines);
add(pnlProgress);
startGame(Level.arrPredefinedLevels[0],false); // "Beginner" level is default
}
public void paintComponent(Graphics g) {
super.paintComponent(g);
}
}
GameParamsWindow.java
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.FocusAdapter;
import java.awt.event.FocusEvent;
import java.awt.event.WindowEvent;
import java.awt.event.WindowAdapter;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.text.DocumentFilter;
import javax.swing.text.AbstractDocument;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
public class GameParamsWindow extends JDialog {
JComboBox<Level> cbDifficulty;
JLabel lblDifficulty,lblCols,lblRows,lblMines;
JNumericTextField txtCols,txtRows,txtMines;
JButton btnStart;
JPanel pnlCommon,pnlBasic,pnlCustomize;
JFrame frmParent;
boolean bCancelled=false;
public class JNumericTextField extends JTextField {
JNumericTextField(int nMaxLength)
{
setPreferredSize(new Dimension(40,20));
AbstractDocument doc=(AbstractDocument) getDocument();
addFocusListener(new FocusAdapter() {
public void focusGained(FocusEvent e) {
SwingUtilities.invokeLater(new Runnable() {
public void run() {
((JNumericTextField)e.getSource()).selectAll();
}
});
}
});
doc.setDocumentFilter(new DocumentFilter() {
public void replace(FilterBypass fb, int offset, int len,
String str, AttributeSet a) throws BadLocationException {
// JNumericTextField allows only digits
String sNumericOnly=str.replaceAll("[^0-9]", "");
super.replace(fb, offset,len, sNumericOnly ,a);
// restrict text length
if(doc.getLength()>nMaxLength) doc.replace(0, doc.getLength(), doc.getText(0, doc.getLength()).substring(0,nMaxLength),a);
}
});
}
public int getNumber()
{
// get integer from JNumericTextField instance
if(getText().isEmpty()) return 0;
return Integer.parseInt(getText());
}
public void setNumber(int num)
{
setText(Integer.toString(num));
}
}
public void setCustomValuesAllowed(boolean bFlag)
{
//(dis)allow setting custom values
txtCols.setEditable(bFlag);
txtRows.setEditable(bFlag);
txtMines.setEditable(bFlag);
}
public GameParamsWindow()
{
initSettingsUI();
setModal(true); // this is a dialog window
}
Level showDialog(JFrame frmParent)
{
Level selectedLevel;
bCancelled=false;
this.frmParent=frmParent;
setLocationRelativeTo(this.frmParent);
cbDifficulty.setSelectedItem(cbDifficulty.getSelectedItem()); // refresh text fields
setVisible(true);
if(bCancelled) { return null; } // dialog was closed with "X" button
selectedLevel=((Level)cbDifficulty.getSelectedItem()); // get selected level
selectedLevel.setParameters(txtCols.getNumber(), txtRows.getNumber(), txtMines.getNumber()); // update level info
return selectedLevel;
}
private void initSettingsUI()
{
setTitle("New Game");
setResizable(false);
pnlCommon=new JPanel();
pnlCommon.setLayout(new BoxLayout(pnlCommon, BoxLayout.Y_AXIS));
pnlBasic = new JPanel(new FlowLayout());
pnlCustomize = new JPanel(new FlowLayout());
lblDifficulty= new JLabel("Difficulty level");
cbDifficulty = new JComboBox<Level>();
txtCols = new JNumericTextField(2); // number of cols
txtRows = new JNumericTextField(2); // number of rows
txtMines = new JNumericTextField(3); // number of mines
cbDifficulty.addActionListener(new ActionListener(){
public void actionPerformed(ActionEvent e) {
Level selectedLevel=(Level)((JComboBox<Level>)e.getSource()).getSelectedItem();
txtCols.setNumber(selectedLevel.nCols);
txtRows.setNumber(selectedLevel.nRows);
txtMines.setNumber(selectedLevel.nTotalMines);
setCustomValuesAllowed(selectedLevel.sName=="Custom");
}
});
for(Level lvl: Level.arrPredefinedLevels) cbDifficulty.addItem(lvl); // push predefined levels to combobox
lblCols = new JLabel("Columns:");
lblRows = new JLabel("Rows:");
lblMines = new JLabel("Mines:");
btnStart = new JButton("Start");
btnStart.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
setVisible(false); // "Start" button is pressed - dialog should be closed
// Now parent window will get the selected level and we are ready to start the game
}
});
pnlBasic.add(lblDifficulty);
pnlBasic.add(cbDifficulty);
pnlBasic.add(btnStart);
pnlCustomize.add(lblCols);
pnlCustomize.add(txtCols);
pnlCustomize.add(lblRows);
pnlCustomize.add(txtRows);
pnlCustomize.add(lblMines);
pnlCustomize.add(txtMines);
pnlCommon.add(pnlBasic);
pnlCommon.add(pnlCustomize);
add(pnlCommon);
pack();
getRootPane().setDefaultButton(btnStart);
addWindowListener( new WindowAdapter() {
@Override
public void windowClosing(WindowEvent we) {
bCancelled=true; // "X" button was clicked
// Dialog is cancelled and game won't be started
}
@Override
public void windowActivated(WindowEvent we)
{
// if dialog is activated - make it's parent visible too
frmParent.setVisible(true);
}
} );
}
}
Level.java
public class Level {
static Level[] arrPredefinedLevels = {
new Level("Beginner",9,9,10),
new Level("Medium", 16,16,40),
new Level("Expert", 30,16,99),
new Level("Custom", 9,9,10)
};
String sName;
public int nCols,nRows,nTotalMines;
final private int MIN_ROWS=9,MAX_ROWS=30,MIN_COLS=9,MAX_COLS=24,MIN_MINES=10; // limits
Level(String name, int cols,int rows, int mines)
{
sName=name;
setParameters(cols,rows,mines);
}
public void setParameters(int cols,int rows, int mines)
{
// Assign values and make sure they aren't out of range (otherwise correct them)
nCols=cols;
nRows=rows;
nTotalMines=mines;
if(nCols<MIN_COLS) nCols=MIN_COLS;
else if(nCols>MAX_COLS) nCols=MAX_COLS;
if(nRows<MIN_ROWS) nRows=MIN_ROWS;
else if(nRows>MAX_ROWS) nRows=MAX_ROWS;
if(nTotalMines<MIN_MINES) nTotalMines=MIN_MINES;
else if(nTotalMines>(nRows-1)*(nCols-1)) nTotalMines=(nRows-1)*(nCols-1);
}
public String toString() { return sName; }
}
MainWindow.java
import java.awt.EventQueue;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import javax.swing.JFrame;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import java.awt.FlowLayout;
import java.awt.Image;
import javax.imageio.ImageIO;
import javax.swing.BoxLayout;
import javax.swing.ImageIcon;
public class MainWindow {
private JFrame frame;
GamePanel pnlGameUI=new GamePanel();
GameParamsWindow dlgNewGame = new GameParamsWindow();
static Image appIcon;
/**
* Launch the application.
*/
public static void main(String[] args) {
EventQueue.invokeLater(new Runnable() {
public void run() {
try {
appIcon=ImageIO.read(getClass().getResource("images/appIcon.png"));
MainWindow window = new MainWindow();
window.frame.setVisible(true);
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
/**
* Create the application.
*/
public MainWindow() {
initialize();
}
/**
* Initialize the contents of the frame.
*/
private void initialize() {
frame = new JFrame("Minesweeper");
frame.setIconImage(appIcon);
frame.setBounds(100, 100, 450, 300);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.getContentPane().setLayout(new BoxLayout(frame.getContentPane(), BoxLayout.Y_AXIS));
frame.setResizable(false);
JMenuBar tbMenu = new JMenuBar();
tbMenu.setLayout(new FlowLayout(FlowLayout.LEFT,0,0));
JMenu mnuGame = new JMenu ("Game");
JMenu mnuAbout = new JMenu ("About");
JMenuItem itemNewGame = new JMenuItem("New Game");
JMenuItem itemExit = new JMenuItem("Exit");
itemNewGame.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent ev) {
Level lvlChosen = dlgNewGame.showDialog(frame);
if(lvlChosen!=null) // if dialog was just closed with "X" button - do nothing
{
pnlGameUI.startGame(lvlChosen, false); // start a new game (level was set inside the dialog)
frame.pack();
}
}
});
itemExit.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent ev) {
System.exit(0);
}
});
mnuAbout.addMouseListener(new MouseAdapter() {
public void mousePressed(MouseEvent me) {
JOptionPane.showMessageDialog(frame,"Minesweeper 1.0, 2021",
"About",JOptionPane.INFORMATION_MESSAGE,new ImageIcon(appIcon));
}
});
mnuGame.add(itemNewGame);
mnuGame.add(itemExit);
tbMenu.add(mnuGame);
tbMenu.add(mnuAbout);
frame.getContentPane().add(tbMenu);
frame.getContentPane().add(pnlGameUI);
frame.pack();
}
}