5
\$\begingroup\$

I've written the following single-script minesweeper console game and would appreciate your feedback!

What I'm interested in:

  • Enums: I think the usage of Enums could be better. It feels weird that I need to make the player_field a List[List[Union[int, PlayerFieldState]]] instead of simply a List[List[PlayerFieldState]]
  • Separation of I/O and game logic: I think there is quite a bit of code which only deals with fixing user input / outputting the field to the user. Is there a nice way to separate both?
  • Anything that would make the code easier to read / more concise

What I'm not interested in:

  • Pure formatting: I use black and isort and I'm pretty happy with that.

Code

You need Python 3.6+ with numpy to execute the following:

# Core Library modules
import enum
from typing import List, Tuple, cast, Union

# Third party modules
import numpy as np


def main():
    game = Game(n=10, m=10, nb_mines=10)
    while game.is_running:
        game.take_step()


class PlayerFieldState(enum.Enum):
    ZERO = 0
    ONE = 1
    TWO = 2
    THREE = 3
    FOUR = 4
    FIVE = 5
    SIX = 6
    SEVEN = 7
    EIGHT = 8
    UNDISCOVERED = 9
    EXPLODED_BOMB = 10
    MARKED = 11


class PlayerAction(enum.Enum):
    EXPLORE = 0
    MARK = 1


field_state2str = {
    0: "  ",
    1: "1 ",
    2: "2 ",
    3: "3 ",
    4: "4 ",
    5: "5 ",
    6: "6 ",
    7: "7 ",
    8: "8 ",
    PlayerFieldState.UNDISCOVERED: "? ",
    PlayerFieldState.EXPLODED_BOMB: "💥",
    PlayerFieldState.MARKED: "📌",
}


class Game:
    def __init__(self, n: int, m: int, nb_mines: int):
        self.mine_field = Game.create_field(n, m, nb_mines)
        self.player_field: List[List[Union[int, PlayerFieldState]]] = [
            [PlayerFieldState.UNDISCOVERED for _ in range(m)] for _ in range(n)
        ]
        self.is_running = True
        self.nb_mines = nb_mines
        self.nb_marked = 0

    @staticmethod
    def create_field(n: int, m: int, nb_mines: int) -> List[List[bool]]:
        field = np.zeros(n * m, dtype=bool)
        for i in range(nb_mines):
            field[i] = True
        np.random.shuffle(field)
        field = field.reshape((n, m))
        return field.tolist()

    def print_field(self, uncover: bool = False):
        top_line = ["  "] + [str(y) + " " for y in range(len(self.player_field[0]))]
        top_line_str = " ".join(top_line)
        print(top_line_str)
        print("-" * len(top_line_str))
        for x, line in enumerate(self.player_field):
            to_print = [str(x) + "|"]
            for y, field in enumerate(line):
                el = field_state2str[field]
                if uncover:
                    if (
                        self.mine_field[x][y]
                        and field != PlayerFieldState.EXPLODED_BOMB
                    ):
                        el = "💣"
                    if field == PlayerFieldState.MARKED:
                        if not self.mine_field[x][y]:
                            el = "😖"
                        else:
                            el = "📌"
                to_print.append(el)
            print(" ".join(to_print))
        print("-" * len(top_line_str))
        print(top_line_str)

    def take_step(self):
        self.print_field()
        x, y, step_type = self.get_valid_input()
        if step_type == PlayerAction.MARK:
            self.take_step_mark_bomb(x, y)
        elif step_type == PlayerAction.EXPLORE:
            self.take_step_explore(x, y)
        else:
            raise ValueError(f"step_type={step_type} is not known")

    def take_step_mark_bomb(self, x: int, y: int):
        if self.player_field[x][y] == PlayerFieldState.MARKED:
            self.player_field[x][y] = PlayerFieldState.UNDISCOVERED
            self.nb_marked -= 1
        elif self.player_field[x][y] == PlayerFieldState.UNDISCOVERED:
            self.player_field[x][y] = PlayerFieldState.MARKED
            self.nb_marked += 1
            self.check_win_condition()

    def take_step_explore(self, x: int, y: int):
        if self.player_field[x][y] == PlayerFieldState.MARKED:
            print(f"Please unmark ({x},{y}) before you hit it.")
        elif self.mine_field[x][y]:
            self.player_field[x][y] = PlayerFieldState.EXPLODED_BOMB
            self.is_running = False
            print("Game over")
            self.print_field(uncover=True)
        else:
            self.uncover(x, y)

    def check_win_condition(self):
        if self.nb_marked == self.nb_mines:
            print(
                f"You have marked {self.nb_marked} fields with mines. "
                "This is equal to the number of hidden mines."
            )
            stop_game = get_yes_no("Do you want to finish the game?")
            if stop_game:
                self.is_running = False
                if self.are_mines_marked_correctly():
                    print("You won!")
                else:
                    print("You lose!")
                    self.print_field(uncover=True)

    def uncover(self, x: int, y: int):
        adjacent_mines = self.get_adjacent_mines(x, y)
        self.player_field[x][y] = adjacent_mines
        if adjacent_mines == 0:
            for xn, yn in self.get_neighbors(x, y):
                if self.player_field[xn][yn] == PlayerFieldState.UNDISCOVERED:
                    self.uncover(xn, yn)

    def get_neighbors(self, x: int, y: int) -> List[Tuple[int, int]]:
        neighbors = []
        for xd in [-1, 0, 1]:
            for yd in [-1, 0, 1]:
                if xd == 0 and yd == 0:
                    continue
                if self.is_on_field(x + xd, y + yd):
                    neighbors.append((x + xd, y + yd))
        return neighbors

    def get_adjacent_mines(self, x: int, y: int) -> int:
        adjacent_mines = 0
        for xn, yn in self.get_neighbors(x, y):
            adjacent_mines += self.is_mine(xn, yn)
        return adjacent_mines

    def is_on_field(self, x: int, y: int) -> bool:
        return 0 <= x < len(self.mine_field) and 0 <= y < len(self.mine_field[0])

    def is_mine(self, x: int, y: int) -> bool:
        if not self.is_on_field(x, y):
            return False
        return self.mine_field[x][y]

    def get_valid_input(self) -> Tuple[int, int, PlayerAction]:
        position = None
        while not self.is_valid_input(position):
            position = input(
                "Where would you like to hit " "(add ',m' if you want to mark a bomb)? "
            )
        position = cast(str, position)
        splitted = position.split(",")
        x = int(splitted[0])
        y = int(splitted[1])
        if len(splitted) == 3:
            if splitted[2] == "m":
                t = PlayerAction.MARK
            else:
                t = PlayerAction.EXPLORE
        else:
            t = PlayerAction.EXPLORE
        return x, y, t

    def is_valid_input(self, position):
        if position is None:
            return False
        if "," not in position:
            return False
        splitted = position.split(",")
        x = splitted[0]
        y = splitted[1]
        if len(splitted) == 3:
            t = splitted[2]
            if t not in ["m", "b"]:
                return False
        try:
            x = int(x)
            y = int(y)
            if not self.is_on_field(x, y):
                return False
            return True
        except:
            return False

    def are_mines_marked_correctly(self):
        for x, line in enumerate(self.mine_field):
            for y, is_mine in enumerate(line):
                if is_mine:
                    if self.player_field[x][y] != PlayerFieldState.MARKED:
                        return False
                else:
                    if self.player_field[x][y] == PlayerFieldState.MARKED:
                        return False
        return True


def get_yes_no(message: str) -> bool:
    value = input(message)
    while value not in ["Y", "y", "N", "n", "yes", "no", "1", "0"]:
        value = input(message + " [y/n] ")
    return value in ["Y", "y", "yes", "1"]


if __name__ == "__main__":
    main()
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Requirements

You need Python 3.6+

So at the least, you should add a shebang:

#!/usr/bin/env python3

with numpy

should go in a requirements.txt.

Definition order

You define main at the top. Call me old-fashioned, but I'm used to stricter languages where the referred symbols in a function need to be defined before that function. I still consider defining things in dependence order to be more legible, and suggest that main go at the bottom.

Enum

Use unique and auto:

@unique
class PlayerFieldState(Enum):
    UNDISCOVERED = auto()
    EXPLODED_BOMB = auto()
    MARKED = auto()

I think it's probably inappropriate to represent the values 0-8 in that enum; they should be stored in a different variable. It looks like you're already going in that direction? given this:

self.player_field: List[List[Union[int, PlayerFieldState]]]

In other words: why have an int-state union if your state captures ints?

Numpy

    return field.tolist()

Why the tolist call? Wouldn't leaving the field as a Numpy array be a more efficient representation?

Iterating coordinates

get_neighbors probably shouldn't return a list. Instead, make it a generator, and don't do iterative concatenation. Something like:

    for xd in [-1, 0, 1]:
        xa = x + xd
        for yd in [-1, 0, 1]:
            ya = y + yd
            if self.is_on_field(xa, ya) and not (xd == 0 and yd == 0):
                yield xa, ya

The same goes for get_adjacent_mines.

Boolean expressions

    if not self.is_on_field(x, y):
        return False
    return self.mine_field[x][y]

can be

return self.is_on_field(x, y) and self.mine_field[x][y]

Sets for validation

while value not in ["Y", "y", "N", "n", "yes", "no", "1", "0"]:
    value = input(message + " [y/n] ")
return value in ["Y", "y", "yes", "1"]

can be

yes_vals = {"Y", "y", "yes", "1"}
no_vals = {"N", "n", "no", "0"}
while value not in yes_vals|no_vals:
    value = input(message + " [y/n] ")
return value in yes_vals

Your loop structure could also be simplified. Just do a while True, do one input, check for membership and return:

yes_vals = {"Y", "y", "yes", "1"}
no_vals = {"N", "n", "no", "0"}
while True:
    value = input(message + " [y/n] ")
    if value in yes_vals|no_vals:
        return value in yes_vals
\$\endgroup\$

Your Answer

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

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