2
\$\begingroup\$

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()

\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

The choice of your function boundaries is actually pretty reasonable, but the same is not true of your class bounds. You've made a classic god class. Separate this into a business logic class that only deals with the inner representation of the board and game state, and a presentation class that only deals with tkinter.

All of your member variables (and their types, when ambiguous) should be first set in the constructor.

Literal[None] is an interesting choice. I'd even say it's more technically true than what people usually do, which is -> None. If you wanted to be pedantic, import NoneType from types; but this is not necessary and you should instead simply write -> None. Formal validators like mypy will understand it.

__init__ returns -> None.

Use lower_snake_case for your method names.

It's more typical to triple-double-quote """ rather than triple-single-quote ''' for docstrings.

You have a mix of \u and literal Unicode emojis sprinkled through your strings. Some of them render on my IDE font and some don't (like the triangular flag), so you're better off being consistent and declaring all Unicode constants like

BLACK_FLAG = '\u2691'

In getNeighbors, delete all of your append()s and write one list literal.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.