This commit is contained in:
eriedaberrie 2023-05-21 15:13:45 -07:00
commit 5e9d532b95
20 changed files with 1306 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
*.class
.breakpoints
.replit
replit.nix

BIN
Audio/explode.wav Normal file

Binary file not shown.

BIN
Audio/silent.wav Normal file

Binary file not shown.

BIN
Audio/tick.wav Normal file

Binary file not shown.

BIN
Audio/win.wav Normal file

Binary file not shown.

385
Canvas.java Normal file
View file

@ -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);
}
}

73
Face.java Normal file
View file

@ -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);
}
}

274
Main.java Normal file
View file

@ -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<Path> 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() {
}
}

58
NumberDisplay.java Normal file
View file

@ -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;
}
}
}

44
Skins/Notes.txt Normal file
View file

@ -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

BIN
Skins/cloneskin.bmp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
Skins/coloursonlyskin.bmp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
Skins/win98skin.bmp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
Skins/winbwskin.bmp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
Skins/winxpblindskin.bmp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
Skins/winxpskin.bmp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

141
Sound.java Normal file
View file

@ -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();
}
}

254
Tile.java Normal file
View file

@ -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<Tile> 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>();
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);
}
}

27
about.html Normal file
View file

@ -0,0 +1,27 @@
<html>
<body style='width: 600px;'>
<h1>About</h1>
<p>
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 <code>Skins/</code>
directory).
</p>
<h2>Credits</h2>
<ul>
<li><b>Skins</b> &mdash; refer to <code>Skins/Notes.txt</code></li>
<li><b>Audio</b> &mdash; directly from Minesweeper for Windows XP</li>
<li><b>Everything else</b> &mdash; me</li>
</ul>
</body>
</html>

46
help.html Normal file
View file

@ -0,0 +1,46 @@
<html>
<body style='width: 600px;'>
<h1>Minesweeper</h1>
<p>
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!
</p>
<br>
<p>
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.
</p>
<h2>Controls</h2>
<table>
<tr>
<td><b>New game</b></td>
<td>Game->New, or F2, or click the face</td>
</tr>
<tr>
<td><b>Reveal tile</b></td>
<td>Left click</td>
</tr>
<tr>
<td><b>Flag tile</b></td>
<td>Right click</td>
</tr>
<tr>
<td><b>Chord tile</b></td>
<td>Left click while right mouse button or shift is held</td>
</tr>
</table>
</body>
</html>