From 11e5379d23f7a224d96ac43254e9ecb60b9a080c Mon Sep 17 00:00:00 2001 From: eriedaberrie Date: Thu, 25 May 2023 19:44:14 -0700 Subject: [PATCH] Add all comments --- Canvas.java | 201 +++++++++++++++++++++++++++++++------------ CustomTextField.java | 2 + Difficulty.java | 64 +++++++++++--- Face.java | 33 ++++++- Main.java | 106 +++++++++++++---------- NumberDisplay.java | 30 +++++-- Options.java | 13 +++ Tile.java | 80 +++++++++++------ 8 files changed, 375 insertions(+), 154 deletions(-) diff --git a/Canvas.java b/Canvas.java index 9b8fc9f..ab494b8 100644 --- a/Canvas.java +++ b/Canvas.java @@ -5,61 +5,99 @@ import java.io.*; import javax.imageio.*; import javax.swing.*; -public class Canvas extends JPanel { +// Canvas class, which draws the board and also acts as a central location of +// game state that tiles and the face can refer back to +public class Canvas extends JComponent { + // Width of the tileset public static final int TS_WIDTH = 144; + // Height of the tileset public static final int TS_HEIGHT = 122; + // Y-position of the digits in the tileset public static final int TS_DIGITS_Y = 33; + // Y-position of the faces in the tilest public static final int TS_FACES_Y = 55; + // Y-position of the miscellaneous stuff in the tileset public static final int TS_MISC_Y = 82; + // X-position of the digit backdrop in the tileset public static final int TS_DIGIT_BACKDROP_X = 28; + // X-position of the color pixel in the tileset public static final int TS_COLOR_X = 70; + // Width of the borders public static final int BORDER_WIDTH = 12; + // Height of most of the borders public static final int BORDER_HEIGHT = 11; + // Height of the bottom border public static final int BOTTOM_HEIGHT = 12; + // Height of the box at the top, without the borders public static final int TOP_BOX_HEIGHT = 33; + // The distance of the top items from the top of the screen public static final int TOP_PADDING = 15; + // The distance of the flags display from the left of the screen public static final int FLAGS_PADDING = 16; + // The distance of the left side of the timer display from the right of the + // screen public static final int TIMER_PADDING = 59; + // The important game attributes as final ints public final int ROWS; public final int COLS; public final int MINES; public final int WIDTH; public final int HEIGHT; + // JPanel containing all the tiles private final JPanel BOARD_PANEL; + // Number displays for the flags and the time private final NumberDisplay FLAGS_DISPLAY; private final NumberDisplay TIMER_DISPLAY; + // The face private final Face FACE; + // A KeyListener listening for shift to help chord private final KeyListener SHIFT_KEY_LISTENER; + // Timer to tick up the time every second private final Timer TIMER; + // The virtual representation of the board if we want to actually reference + // the tiles instead of casting BOARD_PANEL.getComponents() becuase that's + // weird private final Tile[][] BOARD; - private final Image BACKGROUND_IMAGE; + + // The image to draw in the background + private Image backgroundImage; // The tile that the mouse is currently over private Tile currentTile; + // Whether the game has started yet (before the first click, no mines exist + // and the timer doesn't start) private boolean gameStarted; + // Whether the game has ended private boolean gameEnded; + // Whether the game was lost private boolean gameLost; + // The number of tiles left to reveal, to know when to win + private int numTilesLeft; + // The number of flags left to place, for the digit display + private int numFlagsLeft; + // The time, also for the digit display + private int time; + // Input state; not used direclty, but getters for these exist in Tile, and + // this is where we can store the data for the entire game private boolean holdingShift; private boolean leftMouseDown; private boolean rightMouseDown; - private int numTilesLeft; - private int numFlagsLeft; - private int time; + // Set initial values of all the variables private void init() { currentTile = null; gameStarted = false; gameEnded = false; gameLost = false; - holdingShift = false; - leftMouseDown = false; - rightMouseDown = false; numTilesLeft = ROWS * COLS - MINES; numFlagsLeft = MINES; time = 0; + holdingShift = false; + leftMouseDown = false; + rightMouseDown = false; addKeyListener(SHIFT_KEY_LISTENER); @@ -67,17 +105,13 @@ public class Canvas extends JPanel { TIMER_DISPLAY.setNum(time); } - public Canvas(File skin) { - Difficulty difficulty = Options.getDifficulty(); - ROWS = difficulty.getRows(); - COLS = difficulty.getCols(); - MINES = difficulty.getMines(); - WIDTH = Tile.SIZE * COLS + 2 * BORDER_WIDTH; - HEIGHT = Tile.SIZE * ROWS + 2 * BORDER_HEIGHT + BOTTOM_HEIGHT + TOP_BOX_HEIGHT; - + // Get the tileset from Options and use it to set the images of all the + // components inside the canvas and the background image + private void setImages() { + // Read the tileset image BufferedImage tileset; try { - tileset = ImageIO.read(skin); + tileset = ImageIO.read(new File(Options.SKINS_DIR, Options.getSkinName())); // Reject images that are too small to properly form the tileset if (tileset.getWidth() < TS_WIDTH || tileset.getHeight() < TS_HEIGHT) throw new IOException(); @@ -86,20 +120,47 @@ public class Canvas extends JPanel { tileset = new BufferedImage(TS_WIDTH, TS_HEIGHT, BufferedImage.TYPE_INT_RGB); } - BACKGROUND_IMAGE = getBackgroundImage(tileset); - + // Set the images of the tiles Image[] numberTiles = new Image[9]; Image[] specialTiles = new Image[Tile.EXPLODED_MINE_INDEX + 1]; for (int i = 0; i < 9; i++) numberTiles[i] = getTileFromSet(tileset, i, 0); for (int i = 0; i <= Tile.EXPLODED_MINE_INDEX; i++) specialTiles[i] = getTileFromSet(tileset, i, Tile.SIZE); + for (Tile[] row : BOARD) + for (Tile tile : row) + tile.setImages(numberTiles, specialTiles); + // Set the images of the the number displays + Image[] digits = getImageSet(tileset, NumberDisplay.MINUS_INDEX + 1, TS_DIGITS_Y, + NumberDisplay.DIGIT_WIDTH, NumberDisplay.DIGIT_HEIGHT); + Image numberDisplayBackdrop = tileset.getSubimage(TS_DIGIT_BACKDROP_X, TS_MISC_Y, + NumberDisplay.BACKDROP_WIDTH, NumberDisplay.BACKDROP_HEIGHT); + FLAGS_DISPLAY.setImages(digits, numberDisplayBackdrop); + TIMER_DISPLAY.setImages(digits, numberDisplayBackdrop); + + // Set the images of the face + FACE.setImages(getImageSet(tileset, Face.PRESSED_INDEX + 1, TS_FACES_Y, Face.SIZE, Face.SIZE)); + + // Set the background image + backgroundImage = getBackgroundImage(tileset); + } + + public Canvas() { + // Set game attributes based on the difficulty + Difficulty difficulty = Options.getDifficulty(); + ROWS = difficulty.getRows(); + COLS = difficulty.getCols(); + MINES = difficulty.getMines(); + WIDTH = Tile.SIZE * COLS + 2 * BORDER_WIDTH; + HEIGHT = Tile.SIZE * ROWS + 2 * BORDER_HEIGHT + BOTTOM_HEIGHT + TOP_BOX_HEIGHT; + + // Initialize the tiles BOARD = new Tile[ROWS][COLS]; BOARD_PANEL = new JPanel(new GridLayout(ROWS, COLS)); for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { - BOARD[r][c] = new Tile(this, numberTiles, specialTiles); + BOARD[r][c] = new Tile(this); BOARD_PANEL.add(BOARD[r][c]); } } @@ -119,37 +180,36 @@ public class Canvas extends JPanel { } } - Image[] digits = getImageSet(tileset, NumberDisplay.MINUS_INDEX, TS_DIGITS_Y, - NumberDisplay.DIGIT_WIDTH, NumberDisplay.DIGIT_HEIGHT); - Image numberDisplayBackdrop = tileset.getSubimage(TS_DIGIT_BACKDROP_X, TS_MISC_Y, - NumberDisplay.BACKDROP_WIDTH, NumberDisplay.BACKDROP_HEIGHT); + // Initialize the other components + FLAGS_DISPLAY = new NumberDisplay(); + TIMER_DISPLAY = new NumberDisplay(); + FACE = new Face(this); - FLAGS_DISPLAY = new NumberDisplay(numberDisplayBackdrop, digits); - TIMER_DISPLAY = new NumberDisplay(numberDisplayBackdrop, digits); - FACE = new Face(this, getImageSet(tileset, Face.PRESSED_INDEX, TS_FACES_Y, Face.SIZE, Face.SIZE)); + // Now that the components are all initialized, set all of their images + setImages(); + // Shift+LMB chords, so we need some way of determining if the shift key + // is being held. SHIFT_KEY_LISTENER = new KeyAdapter() { - // Shift+LMB chords, so we need some way of determining if the shift - // key is being held. @Override public void keyPressed(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_SHIFT) { - holdingShift = true; - updateCurrentTile(); - } + updateShift(e, true); } @Override public void keyReleased(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_SHIFT) { - holdingShift = false; - updateCurrentTile(); - } + updateShift(e, false); } - private void updateCurrentTile() { - if (currentTile != null) - currentTile.updatePressedState(); + // Helper method to update holdingShift to the new value if shift + // was pressed, and additionally update the pressed state of the + // current tile if that exists + private void updateShift(KeyEvent e, boolean newValue) { + if (e.getKeyCode() == KeyEvent.VK_SHIFT) { + holdingShift = newValue; + if (currentTile != null) + currentTile.updatePressedState(); + } } }; @@ -163,12 +223,13 @@ public class Canvas extends JPanel { return; } + // Play the tick sound and increment the timer number Sound.TICK.play(); TIMER_DISPLAY.setNum(++time); } }); - // Null layout, for manual placement of all game components + // Null layout, for fine-tuned placement of all game components setLayout(null); Insets insets = getInsets(); placeComponent(BOARD_PANEL, BORDER_WIDTH, 2 * BORDER_HEIGHT + TOP_BOX_HEIGHT, insets); @@ -182,11 +243,19 @@ public class Canvas extends JPanel { init(); } + // Refresh the skin and repaint + public void newSkin() { + setImages(); + repaint(); + } + + // Stop everything that might be running public void stop() { TIMER.stop(); removeKeyListener(SHIFT_KEY_LISTENER); } + // Restart the game public void restart() { if (!gameStarted) return; @@ -218,20 +287,22 @@ public class Canvas extends JPanel { TIMER.start(); } + // After every tile revealed, check for game ending public void postRevealCheck() { - if (gameLost) { + if (gameLost) { // Loss Sound.EXPLODE.play(); - stopGame(); + endGame(); FACE.setFace(Face.DEAD_INDEX); - } else if (numTilesLeft == 0) { + } else if (numTilesLeft == 0) { // Win Sound.WIN.play(); // Automatically flag all remaining tiles on win FLAGS_DISPLAY.setNum(0); - stopGame(); + endGame(); FACE.setFace(Face.COOL_INDEX); } } + // Modify the flag counter public void modifyFlagCount(boolean addedFlag) { if (addedFlag) numFlagsLeft--; @@ -240,18 +311,25 @@ public class Canvas extends JPanel { FLAGS_DISPLAY.setNum(numFlagsLeft); } + // Called by tile every single time one is revealed public void revealedSingleTile() { numTilesLeft--; } + // Called by tile if it is a mine when revealed public void setLoseFlag() { gameLost = true; } + // Every tile has a MouseListener, but the canvas must be aware of the mouse + // button state because other tiles that weren't the one that was initially + // clicked also need to know the mouse button state, so Canvas has a setter + // for it public void setLeftMouseDown(boolean b) { if (gameEnded) return; + // Face gets nervous when clicking leftMouseDown = b; if (leftMouseDown) FACE.setFace(Face.SHOCKED_INDEX); @@ -259,18 +337,26 @@ public class Canvas extends JPanel { FACE.setFace(Face.HAPPY_INDEX); } + // Ditto public void setRightMouseDown(boolean b) { - rightMouseDown = b; + if (!gameEnded) + rightMouseDown = b; } public boolean isLeftMouseDown() { return leftMouseDown; } + // Chording can be done with either the right mouse button or the shift key public boolean isChording() { return holdingShift || rightMouseDown; } + // Tiles need to know what tile the mouse is currently over because the + // MouseReleased event is triggered on the tile that the mouse was pressed + // down on, not the one it was released on, but the tile that it was + // released on is the one that actually gets revealed, which we would + // otherwise have no way of knowing public Tile getCurrentTile() { return currentTile; } @@ -283,14 +369,18 @@ public class Canvas extends JPanel { return gameLost; } - private static Image getTileFromSet(BufferedImage tileset, int n, int y) { + // Get the nth tile from the tileset, with the initial y-value of y + private Image getTileFromSet(BufferedImage tileset, int n, int y) { return tileset.getSubimage(Tile.SIZE * n, y, Tile.SIZE, Tile.SIZE); } - private Image[] getImageSet(BufferedImage tileset, int lastIndex, int y, int width, int height) { - Image[] img = new Image[lastIndex + 1]; + // For the other images, which have a 1-pixel gap between them: return an + // array of images by slicing subimages of the given size from the image, + // with a 1-pixel gap between them + private Image[] getImageSet(BufferedImage tileset, int n, int y, int width, int height) { + Image[] img = new Image[n]; int x = 0; - for (int i = 0; i <= lastIndex; i++) { + for (int i = 0; i < n; i++) { img[i] = tileset.getSubimage(x, y, width, height); x += width + 1; } @@ -367,7 +457,8 @@ public class Canvas extends JPanel { c.setSize(c.getPreferredSize()); } - private void stopGame() { + // Stop the moving parts after a win or a loss + private void endGame() { TIMER.stop(); gameEnded = true; for (Tile[] row : BOARD) @@ -377,8 +468,10 @@ public class Canvas extends JPanel { // Override the paintComponent method in order to add the background image @Override - public void paintComponent(Graphics g) { - super.paintComponent(g); - g.drawImage(BACKGROUND_IMAGE, 0, 0, this); + public void paintComponent(Graphics gr) { + super.paintComponent(gr); + Graphics2D g = (Graphics2D) gr; + + g.drawImage(backgroundImage, 0, 0, this); } } diff --git a/CustomTextField.java b/CustomTextField.java index 8d54959..89524db 100644 --- a/CustomTextField.java +++ b/CustomTextField.java @@ -1,7 +1,9 @@ import java.awt.event.*; import javax.swing.*; +// Custom text field for use when creating new custom games public class CustomTextField extends JTextField { + // The number of columns of characters to have private static final int COLS = 4; private final int MIN_VALUE; diff --git a/Difficulty.java b/Difficulty.java index 5ddf4c4..a090e0a 100644 --- a/Difficulty.java +++ b/Difficulty.java @@ -1,7 +1,7 @@ import java.awt.*; -import java.awt.event.*; import javax.swing.*; +// Enum with the difficuulty options public enum Difficulty { BEGINNER (9, 9, 10), INTERMEDIATE (16, 16, 40), @@ -20,6 +20,8 @@ public enum Difficulty { // it's possible to hit the number display cap private static final int MAX_MINES = 999; + // Use nonfinal ints with getter methods because custom needs to be able to + // change its stats private int rows; private int cols; private int mines; @@ -49,6 +51,8 @@ public enum Difficulty { return mines; } + // Show a popup window to select attributes of the custom game, return + // whether or not it succeeded public static boolean setCustom(JFrame frame) { Difficulty current = Options.getDifficulty(); @@ -71,6 +75,7 @@ public enum Difficulty { } } + // Initialize the text fields CustomTextField rowsField = new RowsColsField(current.rows, MIN_SIZE, MAX_ROWS); CustomTextField colsField = new RowsColsField(current.cols, MIN_SIZE, MAX_COLS); // Override CustomTextField in order to make it dynamically determine @@ -89,18 +94,21 @@ public enum Difficulty { }; // Because Java is lame and doesn't have builtin tuples we make do with - // casting an JComponent[][] + // a JComponent[][] to store the structure of the input window JComponent[][] items = { { new JLabel("Height:"), rowsField }, { new JLabel("Width:"), colsField }, { new JLabel("Mines:"), minesField }, }; - int option = JOptionPane.showConfirmDialog(frame, getMessagePanel(items, 6), + // Show the message window now and get the result + int option = JOptionPane.showConfirmDialog(frame, getMessagePanel(items), "Custom Board", JOptionPane.OK_CANCEL_OPTION); + // If user didn't hit OK, bail out now if (option != JOptionPane.OK_OPTION) return false; + // Bail out if fields can't be parsed into integers int rows, cols, mines; try { rows = Integer.parseInt(rowsField.getText()); @@ -110,8 +118,9 @@ public enum Difficulty { return false; } - // Clamp values to the allowed range, just in case something slipped - // through the focusLost events + // Even though the text fields should technically only allow valid + // inputs, we should clamp values to the allowed range, just in case + // something slipped through the focusLost events rows = clampInt(rows, MIN_SIZE, MAX_ROWS); cols = clampInt(cols, MIN_SIZE, MAX_COLS); mines = clampInt(mines, MIN_MINES, getMaxMines(rows, cols)); @@ -120,24 +129,38 @@ public enum Difficulty { if (current == CUSTOM && CUSTOM.rows == rows && CUSTOM.cols == cols && CUSTOM.mines == mines) return false; + // Everything succeeded, so now actually set the new stats CUSTOM.setStats(rows, cols, mines); return true; } + // Helper method to implement something that every language except Java + // already has builtin public static int clampInt(int value, int minValue, int maxValue) { return Math.max(Math.min(value, maxValue), minValue); } + // The regular formula for maximum mines is the product of rows and columns + // minus one, but also cap it at 999 private static int getMaxMines(int rows, int cols) { return Math.min((rows - 1) * (cols - 1), MAX_MINES); } - private static JPanel getMessagePanel(JComponent[][] items, int padding) { + // Create a JPanel that has the items in a custom gridlike layout in which + // every row is as tall as the tallest element in that row and every column + // is as wide as the widest element in that column + private static JPanel getMessagePanel(JComponent[][] items) { + // The padding between the elements and from the walls + final int PADDING = 6; + // The number of columns final int COLS = 2; + // Null layout because the only layout manager that can do what we we + // want is SpringLayout which the same complexity JPanel messagePanel = new JPanel(null); + // Add the items to the panel, setting the label alignment along the way for (JComponent[] row : items) { JLabel label = (JLabel) row[0]; label.setLabelFor(row[1]); @@ -146,32 +169,45 @@ public enum Difficulty { messagePanel.add(row[1]); } - int[] widths = new int[COLS]; - int[] heights = new int[items.length]; + // The maximum width of all the elements of each column + int[] maxWidths = new int[COLS]; + // The maximum height of each elements of each row + int[] maxHeights = new int[items.length]; + // The x-position of all elements of each column int[] x = new int[COLS + 1]; + // The y-position of all the elements of each row int[] y = new int[items.length + 1]; + // The last value of x and y hold the size of the panel itself - x[0] = padding; + // Set x and maxWidths + x[0] = PADDING; for (int c = 0; c < COLS; c++) { + // Get the maximum width among items of this row int maxWidth = Integer.MIN_VALUE; for (int r = 0; r < items.length; r++) maxWidth = Math.max(maxWidth, (int) items[r][c].getPreferredSize().getWidth()); - widths[c] = maxWidth; - x[c + 1] = x[c] + maxWidth + padding; + maxWidths[c] = maxWidth; + // x-coordinate of the next item is the x-coordinate of this one + // plus this one's width plus padding + x[c + 1] = x[c] + maxWidth + PADDING; } + // Set y and maxHeights the same way + y[0] = PADDING; for (int r = 0; r < items.length; r++) { int maxHeight = Integer.MIN_VALUE; for (int c = 0; c < COLS; c++) maxHeight = Math.max(maxHeight, (int) items[r][c].getPreferredSize().getHeight()); - heights[r] = maxHeight; - y[r + 1] = y[r] + maxHeight + padding; + maxHeights[r] = maxHeight; + y[r + 1] = y[r] + maxHeight + PADDING; } + // Actually put into use the values we just got for (int r = 0; r < items.length; r++) for (int c = 0; c < COLS; c++) - items[r][c].setBounds(x[c], y[r], widths[c], heights[r]); + items[r][c].setBounds(x[c], y[r], maxWidths[c], maxHeights[r]); + // Use the last values of x and y to set the size of the panel messagePanel.setPreferredSize(new Dimension(x[COLS], y[items.length])); return messagePanel; diff --git a/Face.java b/Face.java index 6be0d31..bf0ccdb 100644 --- a/Face.java +++ b/Face.java @@ -2,29 +2,42 @@ import java.awt.*; import java.awt.event.*; import javax.swing.*; +// The face at the top of the game public class Face extends JComponent { + // The width and height public static final int SIZE = 26; + // The indexes of each face in the array public static final int HAPPY_INDEX = 0; public static final int SHOCKED_INDEX = 1; public static final int DEAD_INDEX = 2; public static final int COOL_INDEX = 3; public static final int PRESSED_INDEX = 4; + // The main game canvas to refer back to private final Canvas GAME_CANVAS; - private final Image[] FACES; + + private Image[] faces; + // The current face index private int face; + // The last face index, before being pressed, in order to revert on unpress private int unpressedFace; + // Whether the face is being pressed right now private boolean pressed; + // Whether the mouse is over the face private boolean mouseOver; - public Face(Canvas gameCanvas, Image[] faces) { - FACES = faces; + public Face(Canvas gameCanvas) { GAME_CANVAS = gameCanvas; face = HAPPY_INDEX; pressed = false; mouseOver = false; setPreferredSize(new Dimension(SIZE, SIZE)); + // We want to show the pressed sprite only if the mouse is down while + // the mouse is over the face, reverting back when it's no longer over + // the face but coming back as soon as the mouse comes back addMouseListener(new MouseAdapter() { + // The only way to initiate a pressed face state is when the mouse + // is over it and is pressed down @Override public void mousePressed(MouseEvent e) { pressed = true; @@ -32,6 +45,10 @@ public class Face extends JComponent { setFace(PRESSED_INDEX); } + // If the face recieves a mouseReleased event while the mouse is + // over it, that means that it was a completed click, so restart the + // game on the canvas, also resetting back to the happy face that + // should be shown at the start of every game @Override public void mouseReleased(MouseEvent e) { pressed = false; @@ -41,6 +58,8 @@ public class Face extends JComponent { } } + // If it was previously being pressed, reshow the pressed sprite as + // soon as mouse enters @Override public void mouseEntered(MouseEvent e) { mouseOver = true; @@ -48,6 +67,8 @@ public class Face extends JComponent { setFace(PRESSED_INDEX); } + // Temporarily show the unpressed face, but keep the pressed boolean + // true in case the mouse reenters @Override public void mouseExited(MouseEvent e) { mouseOver = false; @@ -57,6 +78,10 @@ public class Face extends JComponent { }); } + public void setImages(Image[] faces) { + this.faces = faces; + } + public void setFace(int face) { this.face = face; repaint(); @@ -67,6 +92,6 @@ public class Face extends JComponent { super.paintComponent(gr); Graphics2D g = (Graphics2D) gr; - g.drawImage(FACES[face], 0, 0, this); + g.drawImage(faces[face], 0, 0, this); } } diff --git a/Main.java b/Main.java index 4dec5a6..c7dbb21 100644 --- a/Main.java +++ b/Main.java @@ -7,21 +7,21 @@ import javax.swing.*; import javax.swing.event.*; public class Main { - public static final String DEFAULT_SKIN = "winxpskin.bmp"; - public static final String SKINS_DIR = "Skins/"; + // HTML filenames to be shown in the Help menu public static final String HELP_FILE = "help.html"; public static final String ABOUT_FILE = "about.html"; - private static final String[] VALID_EXTENSIONS = {".bmp", ".png", ".webp"}; + // Valid file extensions for skins to be listed in the menu + private static final String[] VALID_SKINS_EXTENSIONS = {".bmp", ".png", ".webp"}; + // Game frame private static final JFrame FRAME = new JFrame("Minesweeper"); + // The current game Canvas private static Canvas canvas; - private static String skin; + // Entry point: simply create the frame, set the canvas, and show the frame public static void main(String[] args) { - skin = DEFAULT_SKIN; - FRAME.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); FRAME.setResizable(false); FRAME.setJMenuBar(getMenuBar()); @@ -30,6 +30,7 @@ public class Main { FRAME.setVisible(true); } + // Create the menu bar of the frame private static JMenuBar getMenuBar() { // Button group for the difficulty buttons ButtonGroup difficultyButtons = new ButtonGroup(); @@ -46,12 +47,13 @@ public class Main { difficultyButtons.add(expertItem); difficultyButtons.add(customItem); + // ActionListener for every button except the skins ones ActionListener listener = new ActionListener() { // Keep track of the previously selected difficulty button in case a - // Custom press is canceled so we can revert back to whatever it was - // before + // Custom press is canceled so we can revert the button selection private ButtonModel lastDifficultyButton = difficultyButtons.getSelection(); + // Parse the action command and perform the corresponding action @Override public void actionPerformed(ActionEvent e) { switch (e.getActionCommand()) { @@ -71,6 +73,8 @@ public class Main { lastDifficultyButton = expertItem.getModel(); break; case "custom": + // If canceled, set the currently selected button to + // whatever it was before if (Difficulty.setCustom(FRAME)) { setDifficulty(Difficulty.CUSTOM); lastDifficultyButton = customItem.getModel(); @@ -102,6 +106,7 @@ public class Main { JMenuBar menuBar = new JMenuBar(); + // Create the Game item in the menu bar JMenu gameMenu = new JMenu("Game"); gameMenu.setMnemonic(KeyEvent.VK_G); JMenuItem newGameItem = new JMenuItem("New game"); @@ -123,6 +128,7 @@ public class Main { addMenuItem("Exit", gameMenu, KeyEvent.VK_X, "exit", listener); menuBar.add(gameMenu); + // Create the Help item in the menu bar JMenu helpMenu = new JMenu("Help"); helpMenu.setMnemonic(KeyEvent.VK_H); JMenuItem helpItem = new JMenuItem("Help"); @@ -132,28 +138,32 @@ public class Main { addMenuItem("About", helpMenu, KeyEvent.VK_A, "about", listener); menuBar.add(helpMenu); + // Create the Skins item in the menu bar (this one is special) JMenu skinsMenu = new JMenu("Skins"); skinsMenu.setMnemonic(KeyEvent.VK_S); // The Skins menu should dynamically generate a menu of all image files - // in the skins directory when clicked in order to select one + // in the Skins directory when clicked in order to select one skinsMenu.addMenuListener(new MenuListener() { + // Skins have a separate ActionListener from the other buttons so we + // can simply set the action command to the name of the new skin + // without worrying about collisions or messiness private static ActionListener skinListener = new ActionListener() { @Override public void actionPerformed(ActionEvent e) { // Set the skin to the new one if it isn't already that String newSkin = e.getActionCommand(); - if (newSkin.equals(skin)) + if (newSkin.equals(Options.getSkinName())) return; - skin = newSkin; - replaceCanvas(); + Options.setSkinName(newSkin); + canvas.newSkin(); } }; @Override public void menuSelected(MenuEvent e) { // Get a stream of all files in the skins directory - try (Stream dirStream = Files.list(Paths.get(SKINS_DIR))) { + try (Stream dirStream = Files.list(Paths.get(Options.SKINS_DIR))) { dirStream // Filter for only regular files .filter(Files::isRegularFile) @@ -162,19 +172,17 @@ public class Main { .map(Path::toString) // Only allow a filename if it ends with one of the // valid extensions - .filter(name -> Arrays.stream(VALID_EXTENSIONS) - // Use regionMatches to effectively - // case-insensitively endsWith() - .anyMatch(ext -> name.regionMatches(true, - name.length() - ext.length(), ext, 0, ext.length()))) - // Because Files.list does not guarantee an order, and - // alphabetical files look objectively nicer in lists + .filter(name -> Arrays.stream(VALID_SKINS_EXTENSIONS) + .anyMatch(ext -> name.toLowerCase().endsWith(ext))) + // Because Files.list() does not guarantee an order, + // and alphabetically sorted files look objectively + // nicer in directory listings .sorted() // Create a radio button menu item for each one, // remembering to have it pre-selected if it's already // the current skin - .forEach(name -> addMenuItem(new JRadioButtonMenuItem(name, name.equals(skin)), - skinsMenu, name, skinListener)); + .forEach(name -> addMenuItem(new JRadioButtonMenuItem(name, + name.equals(Options.getSkinName())), skinsMenu, name, skinListener)); } catch (IOException ignore) { // It's fine to leave it blank if we can't get results } @@ -196,27 +204,8 @@ public class Main { return menuBar; } - private static void messageHtmlFile(String file, String failText, String title, int messageType) { - String text; - try { - // For some reason, JLabels just stop parsing HTML after they hit a - // newline, so swap the newlines out with spaces - text = Files.readString(Paths.get(file)).replace('\n', ' '); - } catch (IOException e) { - text = failText; - } - JOptionPane.showMessageDialog(FRAME, text, title, messageType); - } - - private static void setDifficulty(Difficulty newDifficulty) { - // Reject switching difficulty to the already-existing one unless Custom - if (Options.getDifficulty() == newDifficulty && newDifficulty != Difficulty.CUSTOM) - return; - - Options.setDifficulty(newDifficulty); - replaceCanvas(); - } - + // Helper items for adding menu items without needing a million copy-pasted + // lines private static void addMenuItem(String menuTitle, JMenu menu, int mnemonic, String actionCommand, ActionListener listener) { addMenuItem(new JMenuItem(menuTitle), menu, mnemonic, actionCommand, listener); @@ -235,17 +224,40 @@ public class Main { menu.add(menuItem); } - private static void replaceCanvas() { + // Read the given HTML file and message it with JOptionPane, using failText + // instead in case of failure + private static void messageHtmlFile(String file, String failText, String title, int messageType) { + String text; + try { + // For some reason, JLabels just stop parsing HTML after they hit a + // newline, so swap the newlines out with spaces + text = Files.readString(Paths.get(file)).replace('\n', ' '); + } catch (IOException e) { + text = failText; + } + JOptionPane.showMessageDialog(FRAME, text, title, messageType); + } + + // Maybe change the difficulty to the new difficulty + private static void setDifficulty(Difficulty newDifficulty) { + // Reject switching difficulty to the already-existing one unless + // Custom, because that will still be different + if (Options.getDifficulty() == newDifficulty && newDifficulty != Difficulty.CUSTOM) + return; + + Options.setDifficulty(newDifficulty); + + // Replace the canvas with a new one, which will get the new difficulty canvas.stop(); canvas.removeAll(); FRAME.remove(canvas); - setCanvas(); canvas.requestFocusInWindow(); -} + } + // Create a new canvas and put it in the frame private static void setCanvas() { - canvas = new Canvas(new File(SKINS_DIR, skin)); + canvas = new Canvas(); FRAME.add(canvas); FRAME.pack(); } diff --git a/NumberDisplay.java b/NumberDisplay.java index cf2de09..c8e1d87 100644 --- a/NumberDisplay.java +++ b/NumberDisplay.java @@ -1,28 +1,37 @@ import java.awt.*; import javax.swing.*; +// The number displays in the game public class NumberDisplay extends JComponent { + // The dimensions of each individual digit public static final int DIGIT_WIDTH = 11; public static final int DIGIT_HEIGHT = 21; + // The dimensions of the big box public static final int BACKDROP_WIDTH = 41; public static final int BACKDROP_HEIGHT = 25; + // The index of the minus sign public static final int MINUS_INDEX = 10; + // The x-positions of each digit: units -> tens -> hundreds private static final int[] DIGIT_X = {28, 15, 2}; + // The y-position of each digit private static final int DIGIT_Y = 2; - public final Image BACKDROP; - public final Image[] DIGITS; + public Image backdrop; + public Image[] digits; private int num; private boolean negative; - public NumberDisplay(Image backdrop, Image[] digits) { - BACKDROP = backdrop; - DIGITS = digits; + public NumberDisplay() { setPreferredSize(new Dimension(BACKDROP_WIDTH, BACKDROP_HEIGHT)); } + public void setImages(Image[] digits, Image backdrop) { + this.digits = digits; + this.backdrop = backdrop; + } + public void setNum(int num) { setClamped(num); repaint(); @@ -44,17 +53,20 @@ public class NumberDisplay extends JComponent { super.paintComponent(gr); Graphics2D g = (Graphics2D) gr; - g.drawImage(BACKDROP, 0, 0, this); - // Preserve the original num for divison, in case we repaint twice - // without updating the number + g.drawImage(backdrop, 0, 0, this); + // Preserve the original num for in-place divison, because we don't want + // non-graphical side effects in paintComponent int num = this.num; for (int i = 0; i < 3; i++) { int digitsIndex; + // Get the index of the proper digit for this position, drawing the + // minus sign in the hundreds digit if necessary if (negative && i == 2) digitsIndex = MINUS_INDEX; else digitsIndex = num % 10; - g.drawImage(DIGITS[digitsIndex], DIGIT_X[i], DIGIT_Y, this); + g.drawImage(digits[digitsIndex], DIGIT_X[i], DIGIT_Y, this); + // Next digit num /= 10; } } diff --git a/Options.java b/Options.java index 38fec51..19310d9 100644 --- a/Options.java +++ b/Options.java @@ -1,11 +1,16 @@ // Public class that stores all the global static options public class Options { + // Directory with all the skins files + public static final String SKINS_DIR = "Skins/"; + // Whether or not sound is enabled private static boolean sound = false; // Whether or not to force starting at 0 private static boolean protectedStart = false; // The difficulty private static Difficulty difficulty = Difficulty.INTERMEDIATE; + // The skin + private static String skinName = "winxpskin.bmp"; public static boolean hasSound() { return sound; @@ -31,6 +36,14 @@ public class Options { Options.difficulty = difficulty; } + public static String getSkinName() { + return skinName; + } + + public static void setSkinName(String skinName) { + Options.skinName = skinName; + } + // No constructing >:( private Options() { } diff --git a/Tile.java b/Tile.java index 008bc5b..e32783f 100644 --- a/Tile.java +++ b/Tile.java @@ -1,10 +1,13 @@ import java.awt.*; -import java.util.HashSet; +import java.util.ArrayList; import java.awt.event.*; import javax.swing.*; +// A single tile on the game board public class Tile extends JComponent { + // The size of the tile public static final int SIZE = 16; + // The indexes of each special image in the special tiles array public static final int REGULAR_INDEX = 0; public static final int PRESSED_INDEX = 1; public static final int MINE_INDEX = 2; @@ -12,27 +15,35 @@ public class Tile extends JComponent { public static final int NOT_MINE_INDEX = 4; public static final int EXPLODED_MINE_INDEX = 5; + // The main game canvas to refer back to private final Canvas GAME_CANVAS; - private final Image[] NUMBER_TILES; - private final Image[] SPECIAL_TILES; // Store the mouse listener for convenient removal once the game ends private final MouseListener TILE_MOUSE_LISTENER; - // A set of all the Tiles that are adjacent to this one, for calculating - // numbers and autorevealing zeros and chording - private final HashSet ADJACENT_TILES; + // A collection of all the Tiles that are adjacent to this one, for + // calculating numbers and autorevealing zeros and chording + private final ArrayList ADJACENT_TILES; + + // The numbered tiles 0-9 + private Image[] numberTiles; + // All the other possible tiles (see *_INDEX above) + private Image[] specialTiles; // Whether or not the tile is a mine private boolean mine; // The number of mines adjacent to the current one private int num; - // Whether or not this mine has been revealed already + // Tile state private boolean revealed; - // Whether or not this mine has been flagged private boolean flagged; + // Whether this tile should show the pressed sprite (the mouse is down and + // this tile is either the currently moused-over tile or is adjacent to it + // while chording) private boolean pressed; + // Whether the mouse is over the tile private boolean mouseOver; + // Whether the game has ended private boolean gameEnded; private void init() { @@ -46,12 +57,13 @@ public class Tile extends JComponent { addMouseListener(TILE_MOUSE_LISTENER); } - public Tile(Canvas gameCanvas, Image[] numberTiles, Image[] specialTiles) { + public Tile(Canvas gameCanvas) { GAME_CANVAS = gameCanvas; - NUMBER_TILES = numberTiles; - SPECIAL_TILES = specialTiles; - ADJACENT_TILES = new HashSet(); + ADJACENT_TILES = new ArrayList(); + // Every tile has its own mouse listener in order for the game to always + // know when the mouse moves over from one tile to the other in order to + // update the pressed state of the tiles TILE_MOUSE_LISTENER = new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { @@ -122,11 +134,17 @@ public class Tile extends JComponent { init(); } + public void setImages(Image[] numberTiles, Image[] specialTiles) { + this.numberTiles = numberTiles; + this.specialTiles = specialTiles; + } + public void restart() { removeMouseListener(TILE_MOUSE_LISTENER); init(); } + // Update ADJACENT_TILES at the start of the game public void addAdjacentTile(Tile tile) { ADJACENT_TILES.add(tile); } @@ -145,6 +163,7 @@ public class Tile extends JComponent { return true; } + // Flip gameEnded and repaint one last time public void gameEnd() { removeMouseListener(TILE_MOUSE_LISTENER); gameEnded = true; @@ -153,6 +172,7 @@ public class Tile extends JComponent { repaint(); } + // Update the pressed state, whenever something pressing-related happens public void updatePressedState() { if (pressed != (GAME_CANVAS.isLeftMouseDown() && mouseOver)) { pressed = !pressed; @@ -167,6 +187,7 @@ public class Tile extends JComponent { } } + // Flag on unrevealed tiles on right click private void rightClickAction() { if (revealed) return; @@ -176,11 +197,13 @@ public class Tile extends JComponent { repaint(); } + // Chord or reveal tiles on left click private void leftClickAction() { if (GAME_CANVAS.isChording()) { if (!revealed) return; + // Check that chording should happen int adjacentFlags = 0; for (Tile tile : ADJACENT_TILES) if (tile.flagged) @@ -188,12 +211,14 @@ public class Tile extends JComponent { if (adjacentFlags != num) return; + // Chord for (Tile tile : ADJACENT_TILES) if (!tile.flagged) tile.reveal(); } else { if (flagged || revealed) return; + // Ensure that mines are placed before revealing anything GAME_CANVAS.tryStartGame(this); reveal(); @@ -201,6 +226,7 @@ public class Tile extends JComponent { GAME_CANVAS.postRevealCheck(); } + // Reveal the current tile private void reveal() { if (revealed) return; @@ -210,13 +236,15 @@ public class Tile extends JComponent { if (mine) { GAME_CANVAS.setLoseFlag(); - } else { - GAME_CANVAS.revealedSingleTile(); - if (num == 0) - for (Tile tile : ADJACENT_TILES) - if (!tile.flagged) - tile.reveal(); + return; } + + GAME_CANVAS.revealedSingleTile(); + // Recursively reveal all adjacent tiles if this one is 0 + if (num == 0) + for (Tile tile : ADJACENT_TILES) + if (!tile.flagged) + tile.reveal(); } // Determine what image should be drawn at this tile @@ -224,23 +252,23 @@ public class Tile extends JComponent { if (gameEnded && mine) { // Automatically flag mines at wins, losses keep correct flags if (!GAME_CANVAS.isGameLost() || flagged) - return SPECIAL_TILES[FLAGGED_INDEX]; + return specialTiles[FLAGGED_INDEX]; if (revealed) - return SPECIAL_TILES[EXPLODED_MINE_INDEX]; - return SPECIAL_TILES[MINE_INDEX]; + return specialTiles[EXPLODED_MINE_INDEX]; + return specialTiles[MINE_INDEX]; } if (revealed) - return NUMBER_TILES[num]; + return numberTiles[num]; if (flagged) { // Correctly flagged mines would have been caught earlier if (gameEnded) - return SPECIAL_TILES[NOT_MINE_INDEX]; - return SPECIAL_TILES[FLAGGED_INDEX]; + return specialTiles[NOT_MINE_INDEX]; + return specialTiles[FLAGGED_INDEX]; } // Tiles are only interactable if the game is ongoing if (pressed && !gameEnded) - return SPECIAL_TILES[PRESSED_INDEX]; - return SPECIAL_TILES[REGULAR_INDEX]; + return specialTiles[PRESSED_INDEX]; + return specialTiles[REGULAR_INDEX]; } @Override