I’ve just created Minesweeper game, which work perfectly fine (for me). Any suggestions on how to improve this code would be greatly appreciate, in terms of:
- Object-oriented
- Array
- Error handling
- Function
- More efficient approach
- Others: Style(PEP8), comments, clean code, recursion, etc
Explanation of game:
- Minesweeper is an array(table) of hidden mines and non-mine cells.
- You can reveal any cells, one by one. Once you found the mines, you lose.
- If you can revealed all non-mine cells, you win.
- Each non-mine cell show you number of neighbor cell that contain mines.
- Read more Wikipedia, or you can try it on your computer.
My approach:
- First revealed cell can’t be a mine
- Keyboard input only
- Text-based game
- Example: Running on Pythonista (iPad)
Clean version
import random
class Minesweeper:
def __init__(self,width=9,height=10,mine_numbers=12):
self.width = width
self.height = height
self.mine_numbers = mine_numbers
self.table = [None]*self.width*self.height
self.user_cell = False
self.user_row = False
self.user_column = False
self.user_reveal = []
def game_create(self):
print(f'Default size is {self.width}*{self.height}, {self.mine_numbers} mines')
default_size = input('Play default size?(Y/N): ')
if default_size.lower() == 'n':
correct_input = False
while not correct_input:
try:
self.width = int(input('Enter width: '))
self.height = int(input('Enter height: '))
self.mine_numbers = int(input('Enter number of mines: '))
if self.mine_numbers >= self.width*self.height or self.mine_numbers == 0:
print('ERROR: Number of mines can not be 0 or equal/exceed table size')
elif self.width > 99 or self.height > 99:
print('ERROR: Maximum table size is 99*99')
else:
self.table = [None]*self.width*self.height
self.user_reveal = []
correct_input = True
return self.width,self.height,self.mine_numbers,self.table,self.user_reveal
except ValueError:
print('ERROR: Try again, number only')
else:
self.table = [None]*self.width*self.height
self.user_reveal = []
return self.width,self.height,self.mine_numbers,self.table,self.user_reveal
def user_input(self):
correct_input = False
while not correct_input:
try:
self.user_cell = input('Enter {[column][row]} in 4 digits eg. 0105: ')
int(self.user_cell)
if len(self.user_cell) != 4:
print('ERROR: Only 4 digits allowed')
elif int(self.user_cell[2:]) > self.height or self.user_cell[2:] == '00':
print('ERROR: Row out of range')
elif int(self.user_cell[:2]) > self.width or self.user_cell[:2] == '00':
print('ERROR: Column of range')
elif self.user_cell in self.user_reveal:
print('ERROR: Already revealed')
else:
correct_input = True
except ValueError:
print('ERROR: Try again, number only')
if self.user_cell:
self.user_row = int(self.user_cell[2:])
self.user_column = int(self.user_cell[:2])
self.user_reveal.append(self.user_cell)
return self.user_cell,self.user_row,self.user_column
def mines_generator(self):
user_location = ((self.user_row-1)*self.width)+self.user_column-1
possible_location = [i for i in range(self.width*self.height) if i != user_location]
mines_location = random.sample(possible_location,self.mine_numbers)
for i in mines_location:
self.table[i] = 9
return self.table
def two_dimension_array(self):
for i in range(self.height):
self.table[i] = self.table[0+(self.width*i):self.width+(self.width*i)]
del self.table[self.height:]
return self.table
def complete_table(self):
temporary_table = [[None for _ in range(self.width)] for _ in range(self.height)]
for i in range(self.height):
for j in range(self.width):
if self.table[i][j] == 9:
temporary_table[i][j] = 9
continue
else:
counter = 0
for k in range(i-1,i+2):
if 0 <= k <= self.height-1:
for l in range(j-1,j+2):
if 0 <= l <= self.width-1:
if self.table[k][l] == 9:
counter += 1
continue
temporary_table[i][j] = counter
self.table = temporary_table
return self.table
def adjacent_zero(self,zero_cell):
if self.table[int(zero_cell[2:])-1][int(zero_cell[:2])-1] == 0:
for i in range(int(zero_cell[2:])-1-1,int(zero_cell[2:])-1+2):
if 0 <= i < self.height:
for j in range(int(zero_cell[:2])-1-1,int(zero_cell[:2])-1+2):
if 0 <= j < self.width:
if str(j+1).zfill(2)+str(i+1).zfill(2) not in self.user_reveal:
self.user_reveal.append(str(j+1).zfill(2)+str(i+1).zfill(2))
if self.table[i][j] == 0:
self.adjacent_zero(str(j+1).zfill(2)+str(i+1).zfill(2))
return self.user_reveal
def first_turn(self):
self.user_input()
self.mines_generator()
self.two_dimension_array()
self.complete_table()
self.adjacent_zero(self.user_cell)
def print_table(self):
print('\n'*10)
for row in range(self.height+1):
cell = '|'
for column in range(self.width+1):
if row == 0:
cell += f'{column:2}|'
continue
elif column == 0:
cell += f'{row:2}|'
continue
elif str(column).zfill(2)+str(row).zfill(2) in self.user_reveal:
cell += f'{self.table[row-1][column-1]:2}|'
continue
else:
cell += '{:>3}'.format('|')
print(cell)
def end_game(self):
def reveal_mine():
for i,j in enumerate(self.table):
for k,l in enumerate(j):
if l == 9:
self.table[i][k] = ‘XX’
if str(k+1).zfill(2)+str(i+1).zfill(2) not in self.user_reveal:
self.table[i][k] = ‘**’
self.user_reveal.append(str(k+1).zfill(2)+str(i+1).zfill(2))
if self.user_cell:
if self.table[self.user_row-1][self.user_column-1] == 9:
end_game = True
reveal_mine()
self.print_table()
print('YOU LOSE!')
elif len(self.user_reveal) == (self.width*self.height)-self.mine_numbers:
end_game = True
reveal_mine()
self.print_table()
print('YOU WIN!')
else:
end_game = False
else:
end_game = False
return end_game
def restart_game(self):
restart = input('Restart?(Y/N): ')
if restart.lower() == 'y':
return True
else:
return False
def main():
minesweeper = Minesweeper()
while True:
minesweeper.game_create()
minesweeper.print_table()
minesweeper.first_turn()
while not minesweeper.end_game():
minesweeper.print_table()
minesweeper.user_input()
minesweeper.adjacent_zero(minesweeper.user_cell)
if not minesweeper.restart_game():
break
if __name__ == '__main__':
main()
Comment version
import random
class Minesweeper:
def __init__(self,width=9,height=10,mine_numbers=12):
# Table generate: change via tables_ize()
self.width = width
self.height = height
self.mine_numbers = mine_numbers
self.table = [None]*self.width*self.height
# User cell input
self.user_cell = False
self.user_row = False
self.user_column = False
self.user_reveal = []
'''
{user_reveal} is changed by
- {game_create()}: reset user_reveal
- {user_input()}: append input cell, cannot reveal all adjacent 0 here (first turn - table not yet generated)
- {adjacent_zero()}: reveal all adjacent 0
- {end_game()}: append all mines
'''
'''
{*_user_*},{user_cell} = {[column][row]}, index is 1 more than {*_cell_*} index
{*_cell_*} = {[row][column]}, index is 1 less than {*_user_*} index
'''
def game_create(self):
print(f'Default size is {self.width}*{self.height}, {self.mine_numbers} mines')
default_size = input('Play default size?(Y/N): ')
if default_size.lower() == 'n':
correct_input = False
while not correct_input:
try:
self.width = int(input('Enter width: '))
self.height = int(input('Enter height: '))
self.mine_numbers = int(input('Enter number of mines: '))
if self.mine_numbers >= self.width*self.height or self.mine_numbers == 0:
print('ERROR: Number of mines can not be 0 or equal/exceed table size')
elif self.width > 99 or self.height > 99:
print('ERROR: Maximum table size is 99*99')
else:
self.table = [None]*self.width*self.height
self.user_reveal = []
correct_input = True
return self.width,self.height,self.mine_numbers,self.table,self.user_reveal
except ValueError:
print('ERROR: Try again, number only')
else:
self.table = [None]*self.width*self.height
self.user_reveal = []
return self.width,self.height,self.mine_numbers,self.table,self.user_reveal
def user_input(self):
correct_input = False
while not correct_input:
try:
self.user_cell = input('Enter {[column][row]} in 4 digits eg. 0105: ')
int(self.user_cell)
if len(self.user_cell) != 4:
print('ERROR: Only 4 digits allowed')
elif int(self.user_cell[2:]) > self.height or self.user_cell[2:] == '00':
print('ERROR: Row out of range')
elif int(self.user_cell[:2]) > self.width or self.user_cell[:2] == '00':
print('ERROR: Column of range')
elif self.user_cell in self.user_reveal:
print('ERROR: Already revealed')
else:
correct_input = True
except ValueError:
print('ERROR: Try again, number only')
self.user_row = int(self.user_cell[2:])
self.user_column = int(self.user_cell[:2])
if self.user_cell:
self.user_reveal.append(self.user_cell)
return self.user_cell,self.user_row,self.user_column
def mines_generator(self):
# Exclude first cell from mines generator
user_location = ((self.user_row-1)*self.width)+self.user_column-1
possible_location = [i for i in range(self.width*self.height) if i != user_location]
mines_location = random.sample(possible_location,self.mine_numbers)
# Assign 'Location with mine' with 9
for i in mines_location:
self.table[i] = 9
return self.table
def two_dimension_array(self):
# Save table into 2D array
for i in range(self.height):
self.table[i] = self.table[0+(self.width*i):self.width+(self.width*i)]
# Remove unnessessary elements
del self.table[self.height:]
return self.table
def complete_table(self):
# Create temporary 2D array
temporary_table = [[None for _ in range(self.width)] for _ in range(self.height)]
# For every table[i][j]
for i in range(self.height):
for j in range(self.width):
# If table[i][j] is bomb, continue
if self.table[i][j] == 9:
temporary_table[i][j] = 9
continue
else:
counter = 0
# For every adjacent neighbor arrays
for k in range(i-1,i+2):
# Error handling: list index out of range
if 0 <= k <= self.height-1:
for l in range(j-1,j+2):
# Error handling: list index out of range
if 0 <= l <= self.width-1:
# Count every adjacent mines
if self.table[k][l] == 9:
counter += 1
continue
temporary_table[i][j] = counter
self.table = temporary_table
return self.table
def adjacent_zero(self,zero_cell):
# If value is 0
if self.table[int(zero_cell[2:])-1][int(zero_cell[:2])-1] == 0:
# For all neighbor elements
for i in range(int(zero_cell[2:])-1-1,int(zero_cell[2:])-1+2):
# Error handling: index out of range
if 0 <= i < self.height:
for j in range(int(zero_cell[:2])-1-1,int(zero_cell[:2])-1+2):
if 0 <= j < self.width:
# If neighbor element of 0 is not yet append, append all adjacent element
if str(j+1).zfill(2)+str(i+1).zfill(2) not in self.user_reveal:
self.user_reveal.append(str(j+1).zfill(2)+str(i+1).zfill(2))
# If neighbor is also 0, do a recursion
if self.table[i][j] == 0:
self.adjacent_zero(str(j+1).zfill(2)+str(i+1).zfill(2))
def first_turn(self):
self.user_input()
self.mines_generator()
self.two_dimension_array()
self.complete_table()
self.adjacent_zero()
def print_table(self):
# Clear UI
print('\n'*10)
for row in range(self.height+1):
cell = '|'
for column in range(self.width+1):
# Top-row label
if row == 0:
cell += f'{column:2}|' # (Note: try 02 instead of 2)
continue
# First column label
elif column == 0:
cell += f'{row:2}|'
continue
# Revealed cell
elif str(column).zfill(2)+str(row).zfill(2) in self.user_reveal:
cell += f'{self.table[row-1][column-1]:2}|'
continue
# Not yet revealed cell
else:
cell += '{:>3}'.format('|')
print(cell)
def end_game(self):
# If end: reveal all mines, nested function
def reveal_mine():
for i,j in enumerate(self.table):
for k,l in enumerate(j):
if l == 9:
self.table[i][k] = ‘XX’
if str(k+1).zfill(2)+str(i+1).zfill(2) not in self.user_reveal:
self.table[i][k] = ‘**’
self.user_reveal.append(str(k+1).zfill(2)+str(i+1).zfill(2))
# If user choose cell: check if end
if self.user_cell:
if self.table[self.user_row-1][self.user_column-1] == 9:
end_game = True
reveal_mine()
self.print_table()
print('YOU LOSE!')
elif len(self.user_reveal) == (self.width*self.height)-self.mine_numbers:
end_game = True
reveal_mine()
self.print_table()
print('YOU WIN!')
else:
end_game = False
# If no cell selected: end = False
else:
end_game = False
return end_game
def restart_game(self):
restart = input('Restart?(Y/N): ')
if restart.lower() == 'y':
return True
else:
return False
def main():
minesweeper = Minesweeper()
while True:
minesweeper.game_create()
minesweeper.print_table()
minesweeper.first_turn()
while not minesweeper.end_game():
minesweeper.print_table()
minesweeper.user_input()
minesweeper.adjacent_zero(minesweeper.user_cell)
if not minesweeper.restart_game():
break
if __name__ == '__main__':
main()
1 Answer 1
I think implementing a game is a good way to learn a new programming language. If you are up for it, you could try coding a graphical version of Minesweeper using a game library such as Pygame or Arcade to learn even more about the language.
Below are my suggestions for improvements, in no particular order:
Comments
When I review code, I read the code and the comments in tandem. The comments should help me understand what the code does. But for this code I don't think they do, because they are on a quite low level. Comments that tells me essentially the same thing as the code itself aren't very interesting. What the comments instead should communicate is the intent of the code. The why not the how.
I suggest instead of putting comments on individual lines, delete them
all and replace them with a big comment on top of the module where you
describe the architecture of the Minesweeper
class:
import random
# Minesweeper
# -----------
# Minesweeper is a solitaire game in which the goal is to reveal all
# mines on a board. The Minesweeper class implements the game. It has
# the following attributes:
#
# * width: width of board
# ...
# * table: board representation
#
# This example shows how the class should be used:
#
# ms = Minesweeper()
# while True:
# ms.game_create()
# ...
It gives the reader an overview of what the code is all about.
Yes/no prompts
A convention expressed in this Stackoverflow answer is to let the default choice in a yes/no prompt be capitalized. So if the prompt is
Play default size? [Y/n]:
the user knows that by just pressing return, the default size is choosen. Yes, I know it is a "small detail" but to make great software you have to consider these little things.
Integer prompt function
If the user does not want to use the default size, he or she is prompted for the width, height and the number of mines. Consider making a function encapsulating the prompting code:
def prompt_int(name, lo, hi):
while True:
s = input(f'Enter {name} [{lo}..{hi}]: ')
try:
v = int(s)
except ValueError:
print('ERROR: Try again, number only')
continue
if not (lo <= v <= hi):
print('ERROR: Allowed range %d..%d' % (lo, hi))
continue
return v
Unused return values
The return value of the game_create
, user_input
and
mines_generator
methods are unused.
Only initialize attributes in one place
The attributes table
, width
, height
and mine_numbers
are
initialized both in the constructor and in the game_create
method. Better to put the game_create
code in the constructor so
that the initialization only happens once. After all, it is a
constructor so it makes sense that the game is created in it.
Avoid magic numbers
It looks like if a cell contains 9 it is a mine. It is better to avoid magic numbers and to use constants instead. Declare
class Minesweeper:
CLEAR = 0
MINE = 9
and then to check if a cell contains a mine write
self.table[i][j] == Minesweeper.MINE
Board representation
It is unclear to me why the board representation (table
) is
initialized as a one-dimensional array and then changed to a
two-dimensional one. Why not make it two-dimensional from the start?
Counting mines with min
and max
Use min
and max
to count mines in the following way:
def complete_table(self):
width, height, table = self.width, self.height, self.table
for i in range(height):
for j in range(width):
if table[i][j] == Minesweeper.MINE:
continue
table[i][j] = 0
for i2 in range(max(0, i - 1), min(i + 2, height)):
for j2 in range(max(0, j -1), min(j + 2, width)):
if table[i2][j2] == Minesweeper.MINE:
table[i][j] += 1
This usage of min
and max
for bounds-checking is a common
pattern. The first line is just to make the code shorter - so that
self
doesn't have to be repeated everywhere.
Perfer storing data in a machine friendly format
If some input has to be converted in some way to become usable by the computer, then it should be stored in the converted format. That way, the conversion doesn't have to be repeated each time the data is used.
For example, user_column
and user_row
are inputted using 1-based
indexing. Which is fine because that makes sense for humans. But for
Python, 0-based indexing is more convenient so the values should be
converted as soon as they are read from the user. The conversion
simply means subtracting 1 from them.
The same goes for the user_reveal
attribute. It stores a sequence
like this:
['0101', '0201', '0404']
but it should be stored in a Python-friendly format, like this:
[(0, 0), (1, 0), (3, 3)]
Don't store data twice
user_row
and user_column
are after the first turn the same as the
last element of the user_reveal
list. This allows us to remove the
user_row
and user_column
attributes and instead get the same data
by referring to user_reveal[-1]
.
Don't delay returns
In end_game
there is the following:
if ...:
end_game = True
...
elif ...:
end_game = True
...
else:
end_game = False
It is better to express it as follows
if ...:
...
return True
if ...:
...
return True
return False
because it makes the code flow "straighter."
Avoid nested functions
Nested functions have their uses, but they also makes the code harder to follow. IMO, they should be avoided.
Names
Last but not least, the names of many objects can be improved. For methods and functions, it is preferable to include a verb to make the name "active." Here are the renames I suggest:
first_turn => run_first_turn
print_table => print_minefield
mines_generator => place_mines
mine_numbers => mine_count
user_input => read_location
end_game => is_game_over
user_reveal => revealed_locations
(for collection types, you want the name to end with an S)adjacent_zero => reveal_safe_locations
reveal_mine => reveal_all_mines
complete_table => place_mine_counts
table => minefield
Final code
import itertools
import random
# Minesweeper
# -----------
# Minesweeper is a solitaire game in which the goal is to reveal all
# mines on a board. The Minesweeper class implements the game. It has
# the following attributes:
#
# * width: width of board
# ...
# * minefield: board representation
#
# This example shows how the class should be used:
#
# ms = Minesweeper()
# while True:
# ms.game_create()
# ...
def prompt_int(name, lo, hi):
while True:
s = input(f'Enter {name} [{lo}..{hi}]: ')
try:
v = int(s)
except ValueError:
print('ERROR: Try again, number only')
continue
if not (lo <= v <= hi):
print('ERROR: Allowed range %d..%d' % (lo, hi))
continue
return v
class Minesweeper:
CLEAR = 0
MINE = 9
def __init__(self, width = 9, height = 10, mine_count = 12):
self.revealed_locations = []
print(f'Default size is {width}*{height}, {mine_count} mines')
default_size = input('Play default size? [Y/n]: ')
if default_size.lower() == 'n':
self.width = prompt_int('width', 0, 99)
self.height = prompt_int('height', 0, 99)
self.mine_count = prompt_int('number of mines',
0, self.width * self.height - 1)
else:
self.width = width
self.height = height
self.mine_count = mine_count
self.minefield = [[Minesweeper.CLEAR] * self.width
for _ in range(self.height)]
def read_location(self):
while True:
s = input('Enter {[column][row]} in 4 digits eg. 0105: ')
if len(s) != 4:
print('ERROR: Only 4 digits allowed')
continue
try:
row = int(s[2:]) - 1
column = int(s[:2]) - 1
except ValueError:
print('ERROR: Try again, number only')
continue
if not (0 <= row < self.height):
print('ERROR: Row out of range')
elif not (0 <= column < self.width):
print('ERROR: Column of range')
elif (row, column) in self.revealed_locations:
print('ERROR: Already revealed')
else:
break
self.revealed_locations.append((row, column))
def place_mines(self):
locs = set(itertools.product(range(self.height), range(self.width)))
locs -= {self.revealed_locations[-1]}
locs = random.sample(locs, self.mine_count)
for row, column in locs:
self.minefield[row][column] = Minesweeper.MINE
def place_mine_counts(self):
width, height, minefield = self.width, self.height, self.minefield
for i in range(height):
for j in range(width):
if minefield[i][j] == Minesweeper.MINE:
continue
minefield[i][j] = Minesweeper.CLEAR
for i2 in range(max(0, i - 1), min(i + 2, height)):
for j2 in range(max(0, j -1), min(j + 2, width)):
if minefield[i2][j2] == Minesweeper.MINE:
minefield[i][j] += 1
def reveal_safe_locations(self, row, column):
width, height, minefield = self.width, self.height, self.minefield
if minefield[row][column] == Minesweeper.CLEAR:
for i in range(max(0, row - 1), min(row + 2, height)):
for j in range(max(0, column - 1), min(column + 2, width)):
if (i, j) not in self.revealed_locations:
self.revealed_locations.append((i, j))
if minefield[i][j] == Minesweeper.CLEAR:
self.reveal_safe_locations(i, j)
def run_first_turn(self):
self.read_location()
self.place_mines()
self.place_mine_counts()
row, column = self.revealed_locations[-1]
self.reveal_safe_locations(row, column)
def print_minefield(self):
print('\n'*10)
for row in range(self.height + 1):
cell = '|'
for column in range(self.width + 1):
if row == 0 and column == 0:
cell += ' .|'
elif row == 0:
cell += f'{column:2}|'
elif column == 0:
cell += f'{row:2}|'
elif (row - 1, column - 1) in self.revealed_locations:
cell += f'{self.minefield[row-1][column-1]:2}|'
else:
cell += '{:>3}'.format('|')
print(cell)
def reveal_all_mine(self):
for i in range(self.height):
for j in range(self.width):
if self.minefield[i][j] == Minesweeper.MINE:
self.minefield[i][j] = 'XX'
if (i, j) not in self.revealed_locations:
self.minefield[i][j] = '**'
self.revealed_locations.append((i, j))
def is_game_over(self):
row, column = self.revealed_locations[-1]
if self.minefield[row][column] == Minesweeper.MINE:
self.reveal_all_mines()
self.print_minefield()
print('YOU LOSE!')
return True
unmined_locations_count = self.width * self.height - self.mine_count
if len(self.revealed_locations) == unmined_locations_count:
self.reveal_all_mines()
self.print_minefield()
print('YOU WIN!')
return True
return False
def restart_game(self):
restart = input('Restart? [y/N]: ')
return restart.lower() == 'y'
def main():
while True:
ms = Minesweeper()
ms.print_minefield()
ms.run_first_turn()
while not ms.is_game_over():
ms.print_minefield()
ms.read_location()
row, column = ms.revealed_locations[-1]
ms.reveal_safe_locations(row, column)
if not ms.restart_game():
break
if __name__ == '__main__':
main()
-
\$\begingroup\$ IMO, the module docstring should be above the imports and I'd also use
""" Module docstring here. """
instead of multiple#
s. \$\endgroup\$Grajdeanu Alex– Grajdeanu Alex2020年01月02日 16:21:33 +00:00Commented Jan 2, 2020 at 16:21 -
\$\begingroup\$ First of all, thank you. But when the user restart, I want user_input() become default_size so that they don’t have to configure the setting every time. But in your suggestion, we have only init(), and in main(): Minesweeper is also in a while loop. How should I solve this problem? (That’s why I have game_create() separated from init(), and in main(): Minesweeper is placed outside the while loop. But I feel that my solution is not the right one.) // I’ve read your comment 2-3 times, but I need some more times to really understand them all :)) \$\endgroup\$uchaosis– uchaosis2020年01月03日 08:36:07 +00:00Commented Jan 3, 2020 at 8:36
Explore related questions
See similar questions with these tags.
#
character to start it. The weird"""
sections in Python are intended for docstrings, which should only exist once at the start of the function and tell the function what it is for and how to use it. They are not comments and importantly, are not ignored by python. \$\endgroup\$