commit 5e9d532b956e9683759ac8e582af323709b902b5 Author: eriedaberrie Date: Sun May 21 15:13:45 2023 -0700 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aff8bf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.class +.breakpoints +.replit +replit.nix diff --git a/Audio/explode.wav b/Audio/explode.wav new file mode 100644 index 0000000..b64a2e6 Binary files /dev/null and b/Audio/explode.wav differ diff --git a/Audio/silent.wav b/Audio/silent.wav new file mode 100644 index 0000000..ce67b83 Binary files /dev/null and b/Audio/silent.wav differ diff --git a/Audio/tick.wav b/Audio/tick.wav new file mode 100644 index 0000000..b44a95a Binary files /dev/null and b/Audio/tick.wav differ diff --git a/Audio/win.wav b/Audio/win.wav new file mode 100644 index 0000000..9cd1e9c Binary files /dev/null and b/Audio/win.wav differ diff --git a/Canvas.java b/Canvas.java new file mode 100644 index 0000000..81f8bc8 --- /dev/null +++ b/Canvas.java @@ -0,0 +1,385 @@ +import java.awt.*; +import java.awt.event.*; +import java.awt.image.*; +import java.io.*; +import javax.imageio.*; +import javax.swing.*; + +public class Canvas extends JPanel { + public static final int TS_WIDTH = 144; + public static final int TS_HEIGHT = 122; + public static final int TS_DIGITS_Y = 33; + public static final int TS_FACES_Y = 55; + public static final int TS_MISC_Y = 82; + public static final int TS_DIGIT_BACKDROP_X = 28; + public static final int TS_COLOR_X = 70; + public static final int BORDER_WIDTH = 12; + public static final int BORDER_HEIGHT = 11; + public static final int BOTTOM_HEIGHT = 12; + public static final int TOP_BOX_HEIGHT = 33; + public static final int TOP_PADDING = 15; + public static final int FLAGS_PADDING = 16; + public static final int TIMER_PADDING = 59; + + public final int ROWS; + public final int COLS; + public final int MINES; + public final int WIDTH; + public final int HEIGHT; + + private final JPanel BOARD_PANEL; + private final NumberDisplay FLAGS_DISPLAY; + private final NumberDisplay TIMER_DISPLAY; + private final Face FACE; + private final KeyListener SHIFT_KEY_LISTENER; + private final Timer TIMER; + private final Tile[][] BOARD; + private final Image BACKGROUND_IMAGE; + + // The tile that the mouse is currently over + private Tile currentTile; + + private boolean gameStarted; + private boolean gameEnded; + private boolean gameLost; + private boolean holdingShift; + private boolean leftMouseDown; + private boolean rightMouseDown; + private int numTilesLeft; + private int numFlagsLeft; + private int time; + + 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; + + addKeyListener(SHIFT_KEY_LISTENER); + + FLAGS_DISPLAY.setNum(numFlagsLeft); + TIMER_DISPLAY.setNum(time); + } + + public Canvas(int rows, int cols, int mines, File skin) { + ROWS = rows; + COLS = cols; + MINES = mines; + WIDTH = Tile.SIZE * COLS + 2 * BORDER_WIDTH; + HEIGHT = Tile.SIZE * ROWS + 2 * BORDER_HEIGHT + BOTTOM_HEIGHT + TOP_BOX_HEIGHT; + + BufferedImage tileset; + try { + tileset = ImageIO.read(skin); + // Reject images that are too small to properly form the tileset + if (tileset.getWidth() < TS_WIDTH || tileset.getHeight() < TS_HEIGHT) + throw new IOException(); + } catch (IOException e) { + // Just use an empty tileset if it didn't work out + tileset = new BufferedImage(TS_WIDTH, TS_HEIGHT, BufferedImage.TYPE_INT_RGB); + } + + BACKGROUND_IMAGE = getBackgroundImage(tileset); + + 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); + + 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_PANEL.add(BOARD[r][c]); + } + } + // Before doing anything else, we want each tile to know what tiles are + // adjacent to them + for (int r = 0; r < ROWS; r++) { + for (int c = 0; c < COLS; c++) { + // Liberal use of min/max to ensure we don't go out of bounds + int rStart = Math.max(r - 1, 0); + int rEnd = Math.min(r + 2, ROWS); + int cStart = Math.max(c - 1, 0); + int cEnd = Math.min(c + 2, COLS); + for (int rAdj = rStart; rAdj < rEnd; rAdj++) + for (int cAdj = cStart; cAdj < cEnd; cAdj++) + if (rAdj != r || cAdj != c) // Ignore yourself + BOARD[r][c].addAdjacentTile(BOARD[rAdj][cAdj]); + } + } + + 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); + + 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)); + + SHIFT_KEY_LISTENER = new KeyListener() { + // 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(); + } + } + + @Override + public void keyReleased(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_SHIFT) { + holdingShift = false; + updateCurrentTile(); + } + } + + @Override + public void keyTyped(KeyEvent e) { + } + + private void updateCurrentTile() { + if (currentTile != null) + currentTile.updatePressedState(); + } + }; + + // 1 second delay + TIMER = new Timer(1000, new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + // Can't display past 999 anyways + if (time == 999) { + TIMER.stop(); + return; + } + + Sound.TICK.play(); + TIMER_DISPLAY.setNum(++time); + } + }); + + // Null layout, for manual placement of all game components + setLayout(null); + Insets insets = getInsets(); + placeComponent(BOARD_PANEL, BORDER_WIDTH, 2 * BORDER_HEIGHT + TOP_BOX_HEIGHT, insets); + placeComponent(FLAGS_DISPLAY, FLAGS_PADDING, TOP_PADDING, insets); + placeComponent(TIMER_DISPLAY, WIDTH - TIMER_PADDING, TOP_PADDING, insets); + placeComponent(FACE, (WIDTH - Face.SIZE) / 2, TOP_PADDING, insets); + + setPreferredSize(new Dimension(WIDTH, HEIGHT)); + setFocusable(true); + + init(); + } + + public void stop() { + TIMER.stop(); + removeKeyListener(SHIFT_KEY_LISTENER); + } + + public void restart() { + if (!gameStarted) + return; + + stop(); + init(); + for (Tile[] row : BOARD) + for (Tile tile : row) + tile.restart(); + BOARD_PANEL.repaint(); + } + + // Start the game if it's not already started + public void tryStartGame(Tile startTile) { + if (gameStarted) + return; + gameStarted = true; + + // Place all the mines after the the first tile was selected, in such a + // way that the first selected tile must be blank + for (int mineCount = 0; mineCount < MINES;) { + int row = (int) (Math.random() * ROWS); + int col = (int) (Math.random() * COLS); + if (BOARD[row][col].makeMine(startTile)) + mineCount++; + } + + TIMER.start(); + } + + public void postRevealCheck() { + if (gameLost) { + Sound.EXPLODE.play(); + stopGame(); + FACE.setFace(Face.DEAD_INDEX); + } else if (numTilesLeft == 0) { + Sound.WIN.play(); + // Automatically flag all remaining tiles on win + FLAGS_DISPLAY.setNum(0); + stopGame(); + FACE.setFace(Face.COOL_INDEX); + } + } + + public void modifyFlagCount(boolean addedFlag) { + if (addedFlag) + numFlagsLeft--; + else + numFlagsLeft++; + FLAGS_DISPLAY.setNum(numFlagsLeft); + } + + public void revealedSingleTile() { + numTilesLeft--; + } + + public void setLoseFlag() { + gameLost = true; + } + + public void setLeftMouseDown(boolean b) { + if (gameEnded) + return; + + leftMouseDown = b; + if (leftMouseDown) + FACE.setFace(Face.SHOCKED_INDEX); + else + FACE.setFace(Face.HAPPY_INDEX); + } + + public void setRightMouseDown(boolean b) { + rightMouseDown = b; + } + + public boolean isLeftMouseDown() { + return leftMouseDown; + } + + public boolean isChording() { + return holdingShift || rightMouseDown; + } + + public Tile getCurrentTile() { + return currentTile; + } + + public void setCurrentTile(Tile tile) { + currentTile = tile; + } + + public boolean isGameLost() { + return gameLost; + } + + private static 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]; + int x = 0; + for (int i = 0; i <= lastIndex; i++) { + img[i] = tileset.getSubimage(x, y, width, height); + x += width + 1; + } + return img; + } + + // Create the background image, borders and all + private BufferedImage getBackgroundImage(BufferedImage tileset) { + // Get all the images out of the tileset, going row-by-row, hence tsY + // marking the y-index of the current row + int tsY = TS_MISC_Y; + Image topLeftCorner = tileset.getSubimage(0, tsY, BORDER_WIDTH, BORDER_HEIGHT); + Image topBorder = tileset.getSubimage(BORDER_WIDTH + 1, tsY, 1, BORDER_HEIGHT); + Image topRightCorner = tileset.getSubimage(BORDER_WIDTH + 3, tsY, BORDER_WIDTH, BORDER_HEIGHT); + tsY += BORDER_HEIGHT + 1; + Image topLeftBorder = tileset.getSubimage(0, tsY, BORDER_WIDTH, 1); + Image topRightBorder = tileset.getSubimage(BORDER_WIDTH + 3, tsY, BORDER_WIDTH, 1); + tsY += 2; + Image middleLeftCorner = tileset.getSubimage(0, tsY, BORDER_WIDTH, BORDER_HEIGHT); + Image middleBorder = tileset.getSubimage(BORDER_WIDTH + 1, tsY, 1, BORDER_HEIGHT); + Image middleRightCorner = tileset.getSubimage(BORDER_WIDTH + 3, tsY, BORDER_WIDTH, BORDER_HEIGHT); + tsY += BORDER_HEIGHT + 1; + Image bottomLeftBorder = tileset.getSubimage(0, tsY, BORDER_WIDTH, 1); + Image bottomRightBorder = tileset.getSubimage(BORDER_WIDTH + 3, tsY, BORDER_WIDTH, 1); + tsY += 2; + Image bottomLeftCorner = tileset.getSubimage(0, tsY, BORDER_WIDTH, BOTTOM_HEIGHT); + Image bottomBorder = tileset.getSubimage(BORDER_WIDTH + 1, tsY, 1, BOTTOM_HEIGHT); + Image bottomRightCorner = tileset.getSubimage(BORDER_WIDTH + 3, tsY, BORDER_WIDTH, BOTTOM_HEIGHT); + + BufferedImage img = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics g = img.getGraphics(); + + int rightBorderX = WIDTH - BORDER_WIDTH; + int middleBorderY = BORDER_HEIGHT + TOP_BOX_HEIGHT; + int bottomBorderY = HEIGHT - BOTTOM_HEIGHT; + + // Draw all the corners + g.drawImage(topLeftCorner, 0, 0, this); + g.drawImage(topRightCorner, rightBorderX, 0, this); + g.drawImage(middleLeftCorner, 0, middleBorderY, this); + g.drawImage(middleRightCorner, rightBorderX, middleBorderY, this); + g.drawImage(bottomLeftCorner, 0, bottomBorderY, this); + g.drawImage(bottomRightCorner, rightBorderX, bottomBorderY, this); + + // Draw all the horizontal borders + for (int x = BORDER_WIDTH; x < rightBorderX; x++) { + g.drawImage(topBorder, x, 0, this); + g.drawImage(middleBorder, x, middleBorderY, this); + g.drawImage(bottomBorder, x, bottomBorderY, this); + } + // Draw the top set of vertical borders + for (int y = BORDER_HEIGHT; y < middleBorderY; y++) { + g.drawImage(topLeftBorder, 0, y, this); + g.drawImage(topRightBorder, rightBorderX, y, this); + } + // Draw the bottom set of vertical borders + for (int y = middleBorderY + BORDER_HEIGHT; y < bottomBorderY; y++) { + g.drawImage(bottomLeftBorder, 0, y, this); + g.drawImage(bottomRightBorder, rightBorderX, y, this); + } + + // Get the background color out of the tileset and fill the box with it + g.setColor(new Color(tileset.getRGB(TS_COLOR_X, TS_MISC_Y))); + g.fillRect(BORDER_WIDTH, BORDER_HEIGHT, WIDTH - 2 * BORDER_WIDTH, TOP_BOX_HEIGHT); + + g.dispose(); + return img; + } + + // Helper for placing components in the null layout at specific coordinates + private void placeComponent(JComponent c, int x, int y, Insets insets) { + add(c); + Dimension size = c.getPreferredSize(); + c.setBounds(x + insets.left, y + insets.top, size.width, size.height); + } + + private void stopGame() { + TIMER.stop(); + gameEnded = true; + for (Tile[] row : BOARD) + for (Tile tile : row) + tile.gameEnd(); + } + + // Override the paintComponent method in order to add the background image + @Override + public void paintComponent(Graphics g) { + g.drawImage(BACKGROUND_IMAGE, 0, 0, this); + } +} diff --git a/Face.java b/Face.java new file mode 100644 index 0000000..3913cf0 --- /dev/null +++ b/Face.java @@ -0,0 +1,73 @@ +import java.awt.*; +import java.awt.event.*; +import javax.swing.*; + +public class Face extends JComponent { + public static final int SIZE = 26; + 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; + + private final Canvas GAME_CANVAS; + private final Image[] FACES; + private int face; + private int unpressedFace; + private boolean pressed; + private boolean mouseOver; + + public Face(Canvas gameCanvas, Image[] faces) { + FACES = faces; + GAME_CANVAS = gameCanvas; + face = HAPPY_INDEX; + pressed = false; + mouseOver = false; + setPreferredSize(new Dimension(SIZE, SIZE)); + addMouseListener(new MouseListener() { + @Override + public void mousePressed(MouseEvent e) { + pressed = true; + unpressedFace = face; + setFace(PRESSED_INDEX); + } + + @Override + public void mouseReleased(MouseEvent e) { + pressed = false; + if (mouseOver) { + setFace(HAPPY_INDEX); + GAME_CANVAS.restart(); + } + } + + @Override + public void mouseEntered(MouseEvent e) { + mouseOver = true; + if (pressed) + setFace(PRESSED_INDEX); + } + + @Override + public void mouseExited(MouseEvent e) { + mouseOver = false; + if (pressed) + setFace(unpressedFace); + } + + @Override + public void mouseClicked(MouseEvent e) { + } + }); + } + + public void setFace(int face) { + this.face = face; + repaint(); + } + + @Override + public void paintComponent(Graphics g) { + g.drawImage(FACES[face], 0, 0, this); + } +} diff --git a/Main.java b/Main.java new file mode 100644 index 0000000..ba87757 --- /dev/null +++ b/Main.java @@ -0,0 +1,274 @@ +import java.awt.event.*; +import java.io.*; +import java.nio.file.*; +import java.util.stream.Stream; +import java.util.Arrays; +import javax.swing.*; +import javax.swing.event.*; + +public class Main { + private static enum Difficulty { + BEGINNER (9, 9, 10), + INTERMEDIATE (16, 16, 40), + EXPERT (16, 30, 99); + + public final int ROWS; + public final int COLS; + public final int MINES; + + Difficulty(int rows, int cols, int mines) { + this.ROWS = rows; + this.COLS = cols; + this.MINES = mines; + } + } + + public static final String DEFAULT_SKIN = "winxpskin.bmp"; + public static final String SKINS_DIR = "Skins/"; + 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"}; + + private static final JFrame FRAME = new JFrame("Minesweeper"); + + private static Canvas canvas; + private static Difficulty difficulty; + private static String skin; + private static boolean protectedStart; + private static boolean sound; + + public static void main(String[] args) { + difficulty = Difficulty.INTERMEDIATE; + skin = DEFAULT_SKIN; + protectedStart = false; + sound = false; + setCanvas(); + + FRAME.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + FRAME.setResizable(false); + FRAME.setJMenuBar(getMenuBar()); + FRAME.add(canvas); + FRAME.pack(); + FRAME.setVisible(true); + } + + public static boolean isProtectedStart() { + return protectedStart; + } + + public static boolean hasSound() { + return sound; + } + + private static JMenuBar getMenuBar() { + ActionListener listener = new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + switch (e.getActionCommand()) { + case "new": + canvas.restart(); + break; + case "beginner": + setDifficulty(Difficulty.BEGINNER); + break; + case "intermediate": + setDifficulty(Difficulty.INTERMEDIATE); + break; + case "expert": + setDifficulty(Difficulty.EXPERT); + break; + case "custom": + setDifficulty(null); + break; + case "protectedstart": + protectedStart = !protectedStart; + break; + case "sound": + sound = !sound; + Sound.initSounds(); + break; + case "exit": + FRAME.dispatchEvent(new WindowEvent(FRAME, WindowEvent.WINDOW_CLOSING)); + break; + case "help": + messageHtmlFile(HELP_FILE, "Failed to read help file, just google it lol.", + "Minesweeper Help", JOptionPane.QUESTION_MESSAGE); + break; + case "about": + messageHtmlFile(ABOUT_FILE, "I love microsoft!!", + "Minesweeper About", JOptionPane.INFORMATION_MESSAGE); + break; + } + } + }; + + JMenuBar menuBar = new JMenuBar(); + + JMenu gameMenu = new JMenu("Game"); + gameMenu.setMnemonic(KeyEvent.VK_G); + JMenuItem newGameItem = new JMenuItem("New game"); + newGameItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F2, 0)); + addMenuItem(newGameItem, gameMenu, KeyEvent.VK_N, "new", listener); + gameMenu.addSeparator(); + ButtonGroup difficultyButtons = new ButtonGroup(); + JRadioButtonMenuItem beginnerItem = new JRadioButtonMenuItem("Beginner"); + JRadioButtonMenuItem intermediateItem = new JRadioButtonMenuItem("Intermediate", true); + JRadioButtonMenuItem expertItem = new JRadioButtonMenuItem("Advanced"); + JRadioButtonMenuItem customItem = new JRadioButtonMenuItem("Custom..."); + difficultyButtons.add(beginnerItem); + difficultyButtons.add(intermediateItem); + difficultyButtons.add(expertItem); + difficultyButtons.add(customItem); + addMenuItem(beginnerItem, gameMenu, KeyEvent.VK_B, "beginner", listener); + addMenuItem(intermediateItem, gameMenu, KeyEvent.VK_I, "intermediate", listener); + addMenuItem(expertItem, gameMenu, KeyEvent.VK_E, "expert", listener); + addMenuItem(customItem, gameMenu, KeyEvent.VK_C, "custom", listener); + gameMenu.addSeparator(); + JCheckBoxMenuItem protectedStartItem = new JCheckBoxMenuItem("Protected Start", protectedStart); + protectedStartItem.setToolTipText("Guarantees the starting tile has no mines next to it"); + addMenuItem(protectedStartItem, gameMenu, KeyEvent.VK_P, "protectedstart", listener); + JCheckBoxMenuItem soundItem = new JCheckBoxMenuItem("Sound", sound); + addMenuItem(soundItem, gameMenu, KeyEvent.VK_O, "sound", listener); + gameMenu.addSeparator(); + addMenuItem("Exit", gameMenu, KeyEvent.VK_X, "exit", listener); + menuBar.add(gameMenu); + + JMenu helpMenu = new JMenu("Help"); + helpMenu.setMnemonic(KeyEvent.VK_H); + JMenuItem helpItem = new JMenuItem("Help"); + helpItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F1, 0)); + addMenuItem(helpItem, helpMenu, KeyEvent.VK_H, "help", listener); + helpMenu.addSeparator(); + addMenuItem("About", helpMenu, KeyEvent.VK_A, "about", listener); + menuBar.add(helpMenu); + + 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 + skinsMenu.addMenuListener(new MenuListener() { + 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)) + return; + + skin = newSkin; + replaceCanvas(); + } + }; + + @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))) { + dirStream + // Filter for only regular files + .filter(file -> Files.isRegularFile(file)) + // Cut off the directory name from the resulting string + .map(Path::getFileName) + .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 + .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)); + } catch (IOException ignore) { + // It's fine to leave it blank if we can't get results + } + } + + @Override + public void menuDeselected(MenuEvent e) { + // Remove skins when we're done so they don't duplicate every + // time we open the menu + skinsMenu.removeAll(); + } + + @Override + public void menuCanceled(MenuEvent e) { + } + }); + menuBar.add(skinsMenu); + + 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) { + if (newDifficulty == null); + else if (difficulty == newDifficulty) + return; + + difficulty = newDifficulty; + replaceCanvas(); + } + + private static void addMenuItem(String menuTitle, JMenu menu, int mnemonic, String actionCommand, + ActionListener listener) { + addMenuItem(new JMenuItem(menuTitle), menu, mnemonic, actionCommand, listener); + } + + private static void addMenuItem(JMenuItem menuItem, JMenu menu, int mnemonic, String actionCommand, + ActionListener listener) { + menuItem.setMnemonic(mnemonic); + addMenuItem(menuItem, menu, actionCommand, listener); + } + + private static void addMenuItem(JMenuItem menuItem, JMenu menu, String actionCommand, + ActionListener listener) { + menuItem.setActionCommand(actionCommand); + menuItem.addActionListener(listener); + menu.add(menuItem); + } + + private static void replaceCanvas() { + canvas.stop(); + canvas.removeAll(); + FRAME.remove(canvas); + setCanvas(); + } + + private static void setCanvas() { + canvas = getCanvas(); + FRAME.add(canvas); + FRAME.pack(); + } + + private static Canvas getCanvas() { + File skinFile = new File(SKINS_DIR, skin); + if (difficulty == null) { + // TODO: Make the difficulty actually change + return new Canvas(40, 50, 500, skinFile); + } + return new Canvas(difficulty.ROWS, difficulty.COLS, difficulty.MINES, skinFile); + } + + // No construction >:( + private Main() { + } +} diff --git a/NumberDisplay.java b/NumberDisplay.java new file mode 100644 index 0000000..36cfc82 --- /dev/null +++ b/NumberDisplay.java @@ -0,0 +1,58 @@ +import java.awt.*; +import javax.swing.*; + +public class NumberDisplay extends JComponent { + public static final int DIGIT_WIDTH = 11; + public static final int DIGIT_HEIGHT = 21; + public static final int BACKDROP_WIDTH = 41; + public static final int BACKDROP_HEIGHT = 25; + public static final int MINUS_INDEX = 10; + + private static final int[] DIGIT_X = {28, 15, 2}; + private static final int DIGIT_Y = 2; + + public final Image BACKDROP; + public final Image[] DIGITS; + + private int num; + private boolean negative; + + public NumberDisplay(Image backdrop, Image[] digits) { + BACKDROP = backdrop; + DIGITS = digits; + setPreferredSize(new Dimension(BACKDROP_WIDTH, BACKDROP_HEIGHT)); + } + + public void setNum(int num) { + setClamped(num); + repaint(); + } + + private void setClamped(int num) { + // Clamp all number assignments between -99 and 999, inclusive + if (num < 0) { + this.num = Math.min(-num, 99); + negative = true; + } else { + this.num = Math.min(num, 999); + negative = false; + } + } + + @Override + public void paintComponent(Graphics g) { + g.drawImage(BACKDROP, 0, 0, this); + // Preserve the original num for divison, in case we repaint twice + // without updating the number + int num = this.num; + for (int i = 0; i < 3; i++) { + int digitsIndex; + if (negative && i == 2) + digitsIndex = MINUS_INDEX; + else + digitsIndex = num % 10; + g.drawImage(DIGITS[digitsIndex], DIGIT_X[i], DIGIT_Y, this); + num /= 10; + } + } +} diff --git a/Skins/Notes.txt b/Skins/Notes.txt new file mode 100644 index 0000000..65d406a --- /dev/null +++ b/Skins/Notes.txt @@ -0,0 +1,44 @@ +With the exception of the following 2 skins, all other skins here are curated +from the skins pack at https://minesweepergame.com/download/minesweeper-x.php + +winxpblindskin + +Below is the original Notes.txt that came with the Minesweeper X Skins Pack + +-------------------------------------------------------------------------------- + +Minesweeper X Skins + +The following skins are included in this pack: + +winbwskin Curtis Bright inspired by Windows 3.1 monochrome +win98skin Curtis Bright inspired by Windows 98 +winxpskin Curtis Bright inspired by Windows XP + +cloneskin Rodrigo Camargo inspired by Minesweeper Clone +coloursonlyskin Curtis Bright +hibbelerskin Nicholas Hibbeler +icicleskin Aryeh Draeger +marioskin Jonathan Aldrich +mavizskin Mantas Vykertas +mineskin Philip Jovim +narkomaniaskin Kostas Symeonidis inspired by Narkomania +oceanskin C. L. +pacmanskin Jonathan Aldrich +predatorskin Dmitriy Sukhomlynov inspired by Arbiter +scratchskin Kris Decker +symbolskin Tobias Banzhaf +unknownskin Unknown + +vistagreenflowerskin Mohammad Hosseyni 2011 inspired by Windows Vista +vistagreenmineskin Mohammad Hosseyni 2011 inspired by Windows Vista +vistablueflowerskin Mohammad Hosseyni 2011 inspired by Windows Vista +vistabluemineskin Mohammad Hosseyni 2011 inspired by Windows Vista + +greenfarm Josh Scorpius 2021 +classicblue Josh Scorpius 2021 +redgarden Josh Scorpius 2021 +yellowrings Josh Scorpius 2021 +aquarium Josh Scorpius 2021 + + diff --git a/Skins/cloneskin.bmp b/Skins/cloneskin.bmp new file mode 100644 index 0000000..85b78b6 Binary files /dev/null and b/Skins/cloneskin.bmp differ diff --git a/Skins/coloursonlyskin.bmp b/Skins/coloursonlyskin.bmp new file mode 100644 index 0000000..9fa56f6 Binary files /dev/null and b/Skins/coloursonlyskin.bmp differ diff --git a/Skins/win98skin.bmp b/Skins/win98skin.bmp new file mode 100644 index 0000000..9d0c9e7 Binary files /dev/null and b/Skins/win98skin.bmp differ diff --git a/Skins/winbwskin.bmp b/Skins/winbwskin.bmp new file mode 100644 index 0000000..7220dc2 Binary files /dev/null and b/Skins/winbwskin.bmp differ diff --git a/Skins/winxpblindskin.bmp b/Skins/winxpblindskin.bmp new file mode 100644 index 0000000..9f84e71 Binary files /dev/null and b/Skins/winxpblindskin.bmp differ diff --git a/Skins/winxpskin.bmp b/Skins/winxpskin.bmp new file mode 100644 index 0000000..497fa26 Binary files /dev/null and b/Skins/winxpskin.bmp differ diff --git a/Sound.java b/Sound.java new file mode 100644 index 0000000..30ac23a --- /dev/null +++ b/Sound.java @@ -0,0 +1,141 @@ +import java.io.*; +import javax.sound.sampled.*; + +public enum Sound { + // The available sound filenames + WIN("win.wav"), + TICK("tick.wav"), + EXPLODE("explode.wav"), + SILENT("silent.wav"); + + // The directory all the audio files are in + public static final String AUDIO_DIR = "Audio"; + + // Named pipe to send audio requests to on Replit + public static final File REPLIT_PIPE = new File("/tmp/audio"); + + // Use env vars and the existance of the above pipe to determine if running inside Replit + public static final boolean REPLIT_API = System.getenv("REPL_ID") != null && REPLIT_PIPE.exists(); + + // Whether the clips have been initialized yet + private static boolean soundsInitialized = false; + + // File containing the sound + private final File FILE; + + // SoundBackend that will play the sound + private SoundBackend soundBackend; + + // Because Replit does audio totally differently from desktop Java (it + // supposedly supports PulseAudio over VNC but I just could not get that + // working), and I would like to still be able to hear things when I play + // the game directly on my laptop, we have a SoundBackend interface that is + // implemented by both a builtin javax.sound.sampled-powered backend and a + // Replit specific one + private interface SoundBackend { + // plays the specific sound + public void play(); + } + + private class NativeBackend implements SoundBackend { + // The audio clip associated with the sound + private final Clip CLIP; + + public NativeBackend() { + // Create the clip, if possible + Clip clip; + try (AudioInputStream audioIn = AudioSystem.getAudioInputStream(FILE)) { + clip = AudioSystem.getClip(); + clip.open(audioIn); + } catch (Exception e) { + // If it didn't work, don't leave a broken clip behind that + // would try to get played + clip = null; + } + CLIP = clip; + } + + @Override + public void play() { + // Don't play if the clip couldn't be initialized + if (CLIP == null) + return; + + // Rewind the clip and play it + CLIP.stop(); + CLIP.setFramePosition(0); + CLIP.start(); + } + } + + private class ReplitBackend implements SoundBackend { + // The JSON format to send requests to Replit with; the volume is 0.1 + // because my ears got blown out the first time I exploded when it was + // at 100% volume (Replit is LOUD) + private static final String REQUEST_FORMAT = """ + { + "Volume": 0.1, + "Type": "wav", + "Args": { + "Path": "%s" + } + }"""; + + // The actual request string to send + private final String REQUEST; + + public ReplitBackend() { + REQUEST = String.format(REQUEST_FORMAT, FILE); + } + + @Override + public void play() { + // Attempt to send the request + try (FileWriter writer = new FileWriter(REPLIT_PIPE)) { + writer.write(REQUEST); + } catch (IOException ignore) { + // If it didn't work, no biggie, just don't play anything + } + } + } + + Sound(String fileName) { + FILE = new File(AUDIO_DIR, fileName); + } + + // Initialize the backends for all sounds, if they haven't already been + // initialized yet + public static void initSounds() { + if (soundsInitialized) + return; + + soundsInitialized = true; + + if (REPLIT_API) { + for (Sound sound : values()) + sound.soundBackend = sound.new ReplitBackend(); + + // HACK: Play an audible sound immediately, so the popup message + // happens now rather than in the middle of a game; SILENT doesn't + // work here, but the popup will block the first sound anyways so it + // doesn't matter + TICK.play(); + return; + } + + for (Sound sound : values()) + sound.soundBackend = sound.new NativeBackend(); + + // HACK: Play a silent sound as soon as possible to eagerly load all the + // sound stuff now to prevent lag when the first real sound is played + // (yes this actually helps, I tested it) + SILENT.play(); + } + + // The actual method that users outside this file will call to play sound + public void play() { + // Here is the check for if the option for sound is actually enabled + if (Main.hasSound()) + soundBackend.play(); + } +} diff --git a/Tile.java b/Tile.java new file mode 100644 index 0000000..21135d9 --- /dev/null +++ b/Tile.java @@ -0,0 +1,254 @@ +import java.awt.*; +import java.util.HashSet; +import java.awt.event.*; +import javax.swing.*; + +public class Tile extends JComponent { + public static final int SIZE = 16; + public static final int REGULAR_INDEX = 0; + public static final int PRESSED_INDEX = 1; + public static final int MINE_INDEX = 2; + public static final int FLAGGED_INDEX = 3; + public static final int NOT_MINE_INDEX = 4; + public static final int EXPLODED_MINE_INDEX = 5; + + 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; + + // 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 + private boolean revealed; + // Whether or not this mine has been flagged + private boolean flagged; + private boolean pressed; + private boolean mouseOver; + private boolean gameEnded; + + private void init() { + mine = false; + num = 0; + revealed = false; + flagged = false; + pressed = false; + mouseOver = false; + gameEnded = false; + addMouseListener(TILE_MOUSE_LISTENER); + } + + public Tile(Canvas gameCanvas, Image[] numberTiles, Image[] specialTiles) { + GAME_CANVAS = gameCanvas; + NUMBER_TILES = numberTiles; + SPECIAL_TILES = specialTiles; + ADJACENT_TILES = new HashSet(); + + TILE_MOUSE_LISTENER = new MouseListener() { + @Override + public void mousePressed(MouseEvent e) { + boolean rightClicked = false; + switch (e.getButton()) { + case MouseEvent.BUTTON1: + GAME_CANVAS.setLeftMouseDown(true); + break; + case MouseEvent.BUTTON3: + rightClicked = true; + GAME_CANVAS.setRightMouseDown(true); + break; + default: + return; + } + + Tile tile = GAME_CANVAS.getCurrentTile(); + if (tile == null) + return; + + if (rightClicked) + tile.rightClickAction(); + tile.updatePressedState(); + } + + @Override + public void mouseReleased(MouseEvent e) { + boolean leftClicked = false; + switch (e.getButton()) { + case MouseEvent.BUTTON1: + leftClicked = true; + GAME_CANVAS.setLeftMouseDown(false); + break; + case MouseEvent.BUTTON3: + GAME_CANVAS.setRightMouseDown(false); + break; + default: + return; + } + + Tile tile = GAME_CANVAS.getCurrentTile(); + if (tile == null) + return; + + if (leftClicked) + tile.leftClickAction(); + tile.updatePressedState(); + } + + @Override + public void mouseEntered(MouseEvent e) { + GAME_CANVAS.setCurrentTile(Tile.this); + mouseOver = true; + updatePressedState(); + } + + @Override + public void mouseExited(MouseEvent e) { + if (GAME_CANVAS.getCurrentTile() == Tile.this) + GAME_CANVAS.setCurrentTile(null); + mouseOver = false; + updatePressedState(); + } + + @Override + public void mouseClicked(MouseEvent e) { + } + }; + + setPreferredSize(new Dimension(SIZE, SIZE)); + + init(); + } + + public void restart() { + removeMouseListener(TILE_MOUSE_LISTENER); + init(); + } + + public void addAdjacentTile(Tile tile) { + ADJACENT_TILES.add(tile); + } + + // At the start of the game, place a mine at this tile, unless it is + // adjacent to or is the starting tile, or is already a mine. Returns + // whether or not a mine was actually placed. + public boolean makeMine(Tile startTile) { + if (mine || this == startTile || Main.isProtectedStart() && ADJACENT_TILES.contains(startTile)) + return false; + + mine = true; + // Increment the number on the adjacent tiles when a new mine is decided + for (Tile tile : ADJACENT_TILES) + tile.num++; + return true; + } + + public void gameEnd() { + removeMouseListener(TILE_MOUSE_LISTENER); + gameEnded = true; + // Only repaint the tiles that actually change + if (mine || flagged) + repaint(); + } + + public void updatePressedState() { + if (pressed != (GAME_CANVAS.isLeftMouseDown() && mouseOver)) { + pressed = !pressed; + repaint(); + } + boolean pressAdjacent = pressed && GAME_CANVAS.isChording(); + for (Tile tile : ADJACENT_TILES) { + if (tile.pressed != pressAdjacent) { + tile.pressed = pressAdjacent; + tile.repaint(); + } + } + } + + private void rightClickAction() { + if (revealed) + return; + + flagged = !flagged; + GAME_CANVAS.modifyFlagCount(flagged); + repaint(); + } + + private void leftClickAction() { + if (GAME_CANVAS.isChording()) { + if (!revealed) + return; + + int adjacentFlags = 0; + for (Tile tile : ADJACENT_TILES) + if (tile.flagged) + adjacentFlags++; + if (adjacentFlags != num) + return; + + 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(); + } + GAME_CANVAS.postRevealCheck(); + } + + private void reveal() { + if (revealed) + return; + + revealed = true; + repaint(); + + if (mine) { + GAME_CANVAS.setLoseFlag(); + } else { + GAME_CANVAS.revealedSingleTile(); + if (num == 0) + for (Tile tile : ADJACENT_TILES) + if (!tile.flagged) + tile.reveal(); + } + } + + // Determine what image should be drawn at this tile + private Image getImage() { + if (gameEnded && mine) { + // Automatically flag mines at wins, losses keep correct flags + if (!GAME_CANVAS.isGameLost() || flagged) + return SPECIAL_TILES[FLAGGED_INDEX]; + if (revealed) + return SPECIAL_TILES[EXPLODED_MINE_INDEX]; + return SPECIAL_TILES[MINE_INDEX]; + } + if (revealed) + return NUMBER_TILES[num]; + if (flagged) { + // Correctly flagged mines would have been caught earlier + if (gameEnded) + return SPECIAL_TILES[NOT_MINE_INDEX]; + return SPECIAL_TILES[FLAGGED_INDEX]; + } + // Tiles are only interactable if the game is ongoing + if (pressed && !gameEnded) + return SPECIAL_TILES[PRESSED_INDEX]; + return SPECIAL_TILES[REGULAR_INDEX]; + } + + @Override + public void paintComponent(Graphics g) { + g.drawImage(getImage(), 0, 0, this); + } +} diff --git a/about.html b/about.html new file mode 100644 index 0000000..159b0f7 --- /dev/null +++ b/about.html @@ -0,0 +1,27 @@ + + + + +

About

+ +

+ This is a Minesweeper clone in Java that mirrors Minesweeper for + Windows XP, minus the question marks because they are an objectively + bad feature that nobody uses and only exist to slow you down when + you accidentally flag something. The game uses Minesweeper X, + another free Minesweeper clone by Curtis Bright, as reference, and + uses the same custom skin format (see the Skins/ + directory). +

+ +

Credits

+ +
    +
  • Skins — refer to Skins/Notes.txt
  • +
  • Audio — directly from Minesweeper for Windows XP
  • +
  • Everything else — me
  • +
+ + + + diff --git a/help.html b/help.html new file mode 100644 index 0000000..2479a80 --- /dev/null +++ b/help.html @@ -0,0 +1,46 @@ + + + + +

Minesweeper

+ +

+ Minesweeper is a classic game about sweeping everything except + mines. Reveal all the non-mine tiles to win, but attempt to reveal a + mine and you won't be having such a good time. Revealed tiles will + have a number on them to tell you how many mines are directly + adjacent to them. Have fun, and watch out for the mines! +

+ +
+ +

+ Flag tiles to mark them as mines. Chord numbered tiles to reveal all + adjacent non-flagged tiles if there are exactly that many flags + adjacent to the tile. +

+ +

Controls

+ + + + + + + + + + + + + + + + + + +
New gameGame->New, or F2, or click the face
Reveal tileLeft click
Flag tileRight click
Chord tileLeft click while right mouse button or shift is held
+ + + +