Intro
Having spent quite a bit of time learning Python, I thought it was time to work on something. However, an Google search for "python projects 2022" came up with projects I had already completed.
Looking through my projects, however, I came across this. (link). I decided to test my abilities and rewrote the code.
Please review, thanks!
What I am looking for
- Any best practices or performance flaws in my code.
- Proper separation of code into functions.
- Proper usage of type hinting in my functions.
- Code formating, spacing, and logical breaks in my code.
- A better emoji for my mine: currently using 💣 (maybe switch to images?)
The code
import random
import tkinter as tk
import tkinter.messagebox as msgbox
from typing import List, Literal, Tuple
# TODO
# improve astethics and change colors
# add colors of numbers
class Main:
def __init__(self):
self.status_bar_on = True
self.game_started = False
self.gameover = False
self.cols = 30
self.rows = 16
self.flags = 80
self.original_flags = self.flags
self.time = 0
self.widget_board = [[0 for _ in range(self.cols)] for _ in range(self.rows)]
self.setupTkinter()
self.generateBoard()
def run(self) -> Literal[None]:
'''Run the GUI application: call tk.Tk.mainloop.'''
self.window.after(200, self.updateTimer)
self.window.mainloop()
def gameOver(self, won: bool, bad: bool = False) -> Literal[None]:
'''
GUI game over function.
Called by Main.checkWon.
Shows a popup message and also displays all mines: self.showBombs.
'''
self.showBombs()
if bad:
msgbox.showinfo(title = 'Minesweeper', message = f'You lost!')
self.gameover = True
return
if won:
msgbox.showinfo(title = 'Minesweeper', message = f'You won in {self.timer} seconds!')
self.gameover = True
else:
self.window.after(100, self.gameOver(won, bad = True))
def checkWon(self) -> Literal[None]:
'''
Checks the board for a wins.
Uses the following formula:
(amount of opened cells) + (mines) == (total tiles) to check.
Calls Main.gameOver when a win is detected.
'''
opened_cells = 0
for widget in self.board_frame.winfo_children():
if widget.cget('text').isnumeric():
opened_cells += 1
if (self.rows * self.cols) == opened_cells + self.original_flags:
self.gameOver(won = True)
def showBombs(self) -> Literal[None]:
'''
Iterates through the game frame's widgets: (winfo_children) and finds widgets that correspond to mines.
When one is found, if it was correctly marked with a flag, it is marked with a green background.
Otherwise, with a red background.
'''
for row_index, row in enumerate(self.game_board):
for item_index, item in enumerate(row):
if item == '\N{BOMB}':
widget = self.widget_board[row_index][item_index]
if widget.cget('text') == ' ':
widget.config(text = '\N{BOMB}', bg = 'red')
elif widget.cget('text') == '\u2691':
widget.config(bg = 'dark green')
for widget in self.board_frame.winfo_children():
if widget.cget('text') == '\u2691':
if widget.cget('bg') != 'dark green':
widget.config(bg = 'red')
def setupTkinter(self) -> Literal[None]:
'''
An encapsulating function with Tkinter setup and layouting.
Generates the main window along with the status widgets, and frames.
'''
self.window = tk.Tk()
self.window.geometry('1125x590')
self.window.title('Minesweeper')
self.window.config(bg = 'blue')
self.status_bar = tk.Frame(self.window, height = 50)
self.status_bar.config(bg = 'blue')
self.board_frame = tk.Frame(self.window)
self.board_frame.config(bg = 'black')
self.time_variable = tk.StringVar(self.status_bar, value = '⌛ 0')
self.flags_variable = tk.StringVar(self.status_bar, value = f'🚩 {self.flags}')
self.time_label = tk.Label(self.status_bar, textvariable = self.time_variable, font = ('Courier', 30), bg = 'blue')
self.flags_label = tk.Label(self.status_bar, textvariable = self.flags_variable, font = ('Courier', 30), bg = 'blue')
self.generateBoardWidgets(self.board_frame)
if self.status_bar_on:
self.status_bar.pack(fill = tk.BOTH, expand = tk.NO, pady = 5)
self.board_frame.pack(fill = tk.BOTH, expand = tk.YES)
self.time_label.pack(side = tk.LEFT, padx = 5, expand = tk.YES)
self.flags_label.pack(side = tk.RIGHT, padx = 5, expand = tk.YES)
def generateBoardWidgets(self, board_frame: tk.Frame) -> Literal[None]:
'''
Generates the tk.Label widgets in the board frame (board_frame).
Iterates through required rows and columns to generate tk.Label widgets.
Also binds the three buttons to the respective functions.
'''
for row in range(self.rows):
for col in range(self.cols):
widget = tk.Label(
board_frame, text = ' ', fg = 'white', width = 1,
font = ('Courier', 25), bg = 'gray')
widget.bind('<Button-1>', self.handleLeftClick)
widget.bind('<Button-2>', self.handleRightClick)
widget.bind('<Button-3>', self.handleMiddleClick)
widget.grid(row = row, column = col, padx = 2, pady = 2, sticky = tk.NSEW)
tk.Grid.rowconfigure(board_frame, row, weight=1)
tk.Grid.columnconfigure(board_frame, col, weight=1)
self.widget_board[row][col] = widget
def floodfill(self, location, first = False) -> Literal[None]:
'''
Flood fills the tiles where an 0 is clicked on.
This opens all connected groups of zero, and a border around this.
Uses the neighbors from Main.getNeighbors and calls openNeighbors to actually open the tiles.
'''
if first:
self.stack = []
neighbors = self.getNeighbors(*location, [self.rows, self.cols])
self.openNeighbors(*location)
for neighbor_loc in neighbors:
if neighbor_loc in self.stack:
continue
item = self.game_board[neighbor_loc[0]][neighbor_loc[1]]
if item == 0:
self.stack.append(neighbor_loc)
self.floodfill(neighbor_loc)
def openNeighbors(self, y, x) -> Literal[None]:
'''
Opens the neighbors of an tile.
Used by the middle click function (Main.handleMiddleClick) and the floodfill (Main.floodfill)
'''
neighbors = self.getNeighbors(y, x, [self.rows, self.cols])
for neighbor_location in neighbors:
item = self.game_board[neighbor_location[0]][neighbor_location[1]]
widget = self.widget_board[neighbor_location[0]][neighbor_location[1]]
if item == '\N{BOMB}' and widget.cget('text') != '\u2691':
self.gameOver(won = False)
if widget.cget('text') != ' ':
continue
widget.config(text = str(item))
if item == 0:
self.floodfill(neighbor_location, first = True)
def handleLeftClick(self, event = None) -> Literal[None]:
'''
Tkinter event handling for left mouse button.
Will regenerate boards on the first move if the first move is on a mine.
Also floodfills the first move.
'''
if self.gameover or event.widget.cget('text') != ' ':
return
widget = event.widget
row, col = widget.grid_info()['row'], widget.grid_info()['column']
game_board_item = self.game_board[row][col]
if not self.game_started:
while game_board_item != 0:
self.generateBoard()
game_board_item = self.game_board[row][col]
self.floodfill([row, col], first = True)
widget.config(text = str(game_board_item))
if type(game_board_item) != int:
self.gameOver(won = False)
self.game_started = True
self.checkWon()
def handleMiddleClick(self, event = None) -> Literal[None]:
'''
Tkinter event handling for middle click (mouse).
Calls Main.openNeighbors.
'''
if self.gameover:
return
widget = event.widget
row, col = widget.grid_info()['row'], widget.grid_info()['column']
self.openNeighbors(row, col)
def handleRightClick(self, event = None) -> Literal[None]:
'''
Tkinter event handling for right mouse button.
Toggles an flag on the tile if it is not opened.
'''
if self.gameover or event.widget.cget('text') not in (' ', '\u2691') or self.flags <= 0:
return
widget = event.widget
if event.widget.cget('text') == '\u2691':
widget.config(text = ' ')
self.flags += 1
else:
widget.config(text = '\u2691')
self.flags -= 1
self.flags_variable.set(f'🚩 {self.flags}')
def updateTimer(self) -> Literal[None]:
'''
An function that calls itself to update an timer.
Stops once the game is over, and starts on the first click.
'''
if self.gameover:
return
if self.game_started:
self.time += 1
self.time_variable.set(f'⌛ {self.time}')
self.window.after(1000, self.updateTimer)
def generateBoard(self) -> Literal[None]:
'''
Generates the Minesweeper game board.
Places mines, and updates the numbers of neighbors by +1.
Can be called multiple times to regenerate boards.
'''
self.game_board = [[0 for _ in range(self.cols)] for _ in range(self.rows)]
all_locations = []
for x in range(16):
for y in range(30):
all_locations.append((x, y))
sample = random.sample(all_locations, self.flags)
for location in sample:
self.setBomb(*location)
def setBomb(self, y, x) -> Literal[None]:
'''
Sets a spot on the game board to a bomb and increments the neighboring values.
'''
self.game_board[y][x] = '\N{BOMB}'
neighbors = self.getNeighbors(y, x, [self.rows, self.cols])
for neighbor_location in neighbors:
item = self.game_board[neighbor_location[0]][neighbor_location[1]]
if type(item) == int:
self.game_board[neighbor_location[0]][neighbor_location[1]] += 1
def getNeighbors(self, y, x, board_shape) -> List[Tuple[int, int]]:
'''
Returns a list of the indexes of neighboring tiles. (diagonals included)
'''
neighbors = list()
neighbors.append((y, x - 1))
neighbors.append((y, x + 1))
neighbors.append((y - 1, x))
neighbors.append((y - 1, x - 1))
neighbors.append((y - 1, x + 1))
neighbors.append((y + 1, x))
neighbors.append((y + 1, x + 1))
neighbors.append((y + 1, x - 1))
neighbors = [i for i in neighbors if i[0] in range(board_shape[0]) and i[1] in range(board_shape[1])]
return neighbors
if __name__ == '__main__':
app = Main()
app.run()