Python 3 Curses Terminal Tetris

Sep 14 2020

Uczyłem się programowania własnego przez kilka lat, głównie używając Pythona i stworzyłem podobną do tetrisa grę z wierszem poleceń, używając biblioteki Pythona Curses. Gra wydaje się działać zgodnie z przeznaczeniem; ale brakuje mi doświadczenia w pisaniu kodu zorientowanego obiektowo. Tak więc chciałbym uzyskać opinię na temat struktury kodu i zakładając, że struktura jest w porządku, styl kodu.

from copy import deepcopy
import time
import curses
import random


class TetrisPiece:
    def __init__(self, indices, center_of_rotation, color):
        self.indices = indices
        self.center_of_rotation = center_of_rotation
        self.last_move_overlap = False
        self.color = color


class TetrisBoard:
    def __init__(self, num_rows, num_columns):
        self.num_rows = num_rows
        self.num_columns = num_columns

        self.array = [[0] * self.num_columns for _ in range(self.num_rows)]
        self.active_piece = None

    def in_bounds(self, temp_active_piece_indices):
        return all(0 <= i < self.num_rows and 0 <= j < self.num_columns
                   for i, j in temp_active_piece_indices)

    def no_overlap(self, temp_active_piece_indices):
        return all(self.array[i][j] == 0 for i, j in
                   set(temp_active_piece_indices) - set(self.active_piece.indices))

    def add_piece(self, piece):
        # try to place Piece near top center of board
        new_active_piece_indices = [(i, j + int(self.num_columns / 2) - 1)
                                    for i, j in piece.indices]

        if all(self.array[i][j] == 0 for i, j in new_active_piece_indices):
            self.active_piece = piece
            self.update_array(new_active_piece_indices)
            piece.indices = new_active_piece_indices
            piece.center_of_rotation[1] += int(self.num_columns / 2) - 1
            piece.last_move_overlap = False
        else:
            piece.last_move_overlap = True

    def rotate_active_piece(self):
        # rotates active piece indices 90 degrees counter clockwise about it's
        # center of a rotation

        x, y = self.active_piece.center_of_rotation

        # this translates the active piece so that it's center is
        # the origin, then rotates each point in indices about the origin,
        # then translates the piece so that it's center is at it's
        # original position
        temp_active_piece_indices = [(int(-j + y + x), int(i - x + y))
                                     for i, j in self.active_piece.indices]

        if (self.in_bounds(temp_active_piece_indices)
                and self.no_overlap(temp_active_piece_indices)):
            self.update_array(temp_active_piece_indices)
            self.active_piece.indices = temp_active_piece_indices

    def translate_active_piece(self, direction):
        if direction == 'right':
            x, y = 0, 1
        elif direction == 'left':
            x, y = 0, -1
        elif direction == 'down':
            x, y = 1, 0

        temp_active_piece_indices = [(i + x, j + y)
                                     for i, j in self.active_piece.indices]
        if (self.in_bounds(temp_active_piece_indices)
                and self.no_overlap(temp_active_piece_indices)):
            self.update_array(temp_active_piece_indices)
            self.active_piece.indices = temp_active_piece_indices
            self.active_piece.center_of_rotation[0] += x
            self.active_piece.center_of_rotation[1] += y

            self.active_piece.last_move_overlap = False

        elif (self.in_bounds(temp_active_piece_indices)
              and not self.no_overlap(temp_active_piece_indices)):
            self.active_piece.last_move_overlap = True

        # this is necessary to tell when a piece hits the bottom of the
        # board
        elif not self.in_bounds(temp_active_piece_indices) and direction == 'down':
            self.active_piece.last_move_overlap = True

    def update_array(self, new_indices):
        for i, j in self.active_piece.indices:
            self.array[i][j] = 0
        for i, j in new_indices:
            self.array[i][j] = self.active_piece.color


class CursesWindow:
    def __init__(self, game):
        self.game = game
        self.window = None

    def update(self):
        pass

    def refresh(self):
        self.window.refresh()

    def addstr(self, y, x, string):
        self.window.addstr(y, x, string)


class BoardWindow(CursesWindow):
    def __init__(self, game):
        CursesWindow.__init__(self, game)

        # the window's border adds two extra rows and two extra columns
        self.num_rows = game.board.num_rows + 2
        self.num_columns = game.board.num_columns + 2

        self.window = curses.newwin(
            self.num_rows,
            self.num_columns
        )

        self.window.border('*', '*', '*', '*', '*', '*', '*', '*')
        self.update()

    def update(self):
        # only update the interior of the window
        for i in range(self.num_rows - 2):
            for j in range(self.num_columns - 2):
                if self.game.board.array[i][j] != 0:
                    self.window.addstr(
                        i + 1,
                        j + 1,
                        '1',
                        curses.color_pair(self.game.board.array[i][j])
                    )
                else:
                    self.window.addstr(i + 1, j + 1, '.')
        self.window.refresh()

    def keypad(self, flag):
        self.window.keypad(flag)

    def nodelay(self, flag):
        self.window.nodelay(flag)

    def getch(self):
        return self.window.getch()


class ScoreWindow(CursesWindow):
    def __init__(self, game, board_window):
        CursesWindow.__init__(self, game)
        self.num_items_to_display = 3

        # the window's border adds two extra rows
        self.num_rows = self.num_items_to_display + 2

        # 6 digits for the string 'score:' + max_num_score_digits + 2 for border
        self.num_columns = 6 + game.max_num_score_digits + 2

        self.window = curses.newwin(
            self.num_rows,
            self.num_columns,
            0,
            board_window.num_columns + 1
        )

        self.update()

    def update(self):
        self.window.erase()
        self.window.border('*', '*', '*', '*', '*', '*', '*', '*')
        self.window.addstr(1, 1, f'Score:{self.game.score}')
        self.window.addstr(2, 1, f'Lines:{self.game.lines_completed}')
        self.window.addstr(3, 1, f'Level:{self.game.level}')
        self.window.refresh()


class PiecePreviewWindow(CursesWindow):
    def __init__(self, game, board_window, score_window):
        CursesWindow.__init__(self, game)

        # the window's border adds two extra rows and two extra columns
        self.num_rows = game.max_piece_length + 2
        self.num_columns = game.max_piece_length + 2

        self.window = curses.newwin(
            self.num_rows,
            self.num_columns,
            score_window.num_rows,
            board_window.num_columns + 1
        )

        self.window.border('*', '*', '*', '*', '*', '*', '*', '*')
        self.update()

    def update(self):
        self.window.erase()

        # only update the interior of the window
        for i in range(self.num_rows - 2):
            for j in range(self.num_columns - 2):
                if (i, j) in self.game.next_piece.indices:
                    self.window.addstr(
                        i + 1,
                        j + 1,
                        '1',
                        curses.color_pair(self.game.next_piece.color)
                    )

        self.window.refresh()


class GUI:
    def __init__(self, game):
        self.game = game

        curses.initscr()
        curses.start_color()
        curses.noecho()
        curses.cbreak()
        curses.curs_set(0)

        curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK)
        curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK)
        curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK)
        curses.init_pair(4, curses.COLOR_BLUE, curses.COLOR_BLACK)
        curses.init_pair(5, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
        curses.init_pair(6, curses.COLOR_CYAN, curses.COLOR_BLACK)
        curses.init_pair(7, curses.COLOR_WHITE, curses.COLOR_BLACK)

        self.board_window = BoardWindow(game)
        self.score_window = ScoreWindow(game, self.board_window)
        self.piece_preview_window = PiecePreviewWindow(game, self.board_window,
                                                       self.score_window)

        self.board_window.keypad(True)
        self.board_window.nodelay(True)


class Game:
    def __init__(self, board_num_rows, board_num_columns):
        self.board = TetrisBoard(board_num_rows, board_num_columns)

        self.score = 0
        self.max_num_score_digits = 8
        self.lines_completed = 0
        self.level = 0

        self.SPACE_KEY_VALUE = 32

        # approximate frame rate
        self.frame_rate = 60

        self.pieces = [
            TetrisPiece([(0, 1), (1, 1), (2, 1), (3, 1)], [1.5, 1.5], 1),  # I
            TetrisPiece([(0, 1), (1, 1), (2, 1), (2, 2)], [1, 1], 2),  # J
            TetrisPiece([(0, 1), (1, 1), (2, 1), (2, 0)], [1, 1], 3),  # L
            TetrisPiece([(0, 0), (0, 1), (1, 0), (1, 1)], [.5, .5], 4),  # O
            TetrisPiece([(1, 0), (1, 1), (0, 1), (0, 2)], [1, 1], 5),  # S
            TetrisPiece([(1, 0), (1, 1), (1, 2), (0, 1)], [1, 1], 6),  # T
            TetrisPiece([(0, 0), (0, 1), (1, 1), (1, 2)], [1, 1], 7)  # Z
        ]

        self.max_piece_length = 4
        
        self.next_piece = deepcopy(random.choice(self.pieces))

        self.GUI = GUI(self)

    def points(self, number_of_lines):
        coefficients = [0, 40, 100, 300, 1200]
        return coefficients[number_of_lines] * (self.level + 1)

    def main_loop(self):
        self.board.add_piece(self.next_piece)
        self.next_piece = deepcopy(random.choice(self.pieces))
        self.GUI.piece_preview_window.update()

        loop_count = 0
        while True:
            keyboard_input = self.GUI.board_window.getch()

            loop_count += 1

            force_move = (loop_count % max(self.frame_rate - self.level, 1) == 0)
            hard_drop = (keyboard_input == self.SPACE_KEY_VALUE)
            if force_move or hard_drop:
                if hard_drop:
                    while not self.board.active_piece.last_move_overlap:
                        self.board.translate_active_piece('down')

                    self.GUI.board_window.update()
                    time.sleep(.5)

                elif force_move:
                    self.board.translate_active_piece('down')

                if self.board.active_piece.last_move_overlap:
                    # try to clear lines one at a time starting from the top of
                    # the screen
                    line_count = 0
                    for row_number, row in enumerate(self.board.array):
                        if all(row):
                            # highlight row to be deleted
                            # add 1 to row_number because of board_window's border
                            self.GUI.board_window.addstr(
                                row_number + 1, 1, '=' * self.board.num_columns
                            )

                            self.GUI.board_window.refresh()
                            time.sleep(.5)

                            # delete row
                            del self.board.array[row_number]
                            self.board.array.insert(0, [0] * self.board.num_columns)

                            self.GUI.board_window.update()
                            time.sleep(.5)

                            line_count += 1

                    self.score += self.points(line_count)
                    self.lines_completed += line_count
                    self.level = self.lines_completed // 2

                    # Basically, reset the game to prevent the strings
                    # corresponding to the score, lines_completed, or level
                    # variables from exceeding the dimensions the score_window
                    if len(str(self.score)) > self.max_num_score_digits:
                        self.score = 0
                        self.level = 0
                        self.lines_completed = 0

                    self.GUI.score_window.update()

                    # try to add nextPiece to Board
                    self.board.add_piece(self.next_piece)

                    # if unsuccessful, gameover
                    if self.next_piece.last_move_overlap:
                        break

                    self.next_piece = deepcopy(random.choice(self.pieces))
                    self.GUI.piece_preview_window.update()

            else:
                if keyboard_input == ord('w'):
                    self.board.rotate_active_piece()
                if keyboard_input == ord('d'):
                    self.board.translate_active_piece('right')
                if keyboard_input == ord('s'):
                    self.board.translate_active_piece('down')
                if keyboard_input == ord('a'):
                    self.board.translate_active_piece('left')
                # exit game
                if keyboard_input == ord('e'):
                    break

            self.GUI.board_window.update()

            # delay after a rotation
            if keyboard_input == ord('w'):
                time.sleep(.25)

            time.sleep(1 / self.frame_rate)

        # Reset terminal window before exiting the game.
        curses.nocbreak()
        self.GUI.board_window.keypad(False)
        self.GUI.board_window.nodelay(False)
        curses.echo()
        curses.endwin()
        curses.curs_set(1)

        print('Game Over')
        exit()


# Run the game
game = Game(board_num_rows=16, board_num_columns=10)
game.main_loop()

Odpowiedzi

5 user985366 Sep 14 2020 at 05:55

Ogólnie wygląda na uporządkowany, używa dobrych nazw, jest podzielony na funkcje itp. To są dobre rzeczy.

Mam kilka uwag, ale pamiętaj, że to moje opinie, nie mogę powiedzieć, że to dobrze, czy źle i nie będę odnosić się do żadnego standardu ani stylu kodu.

1

def in_bounds(self, temp_active_piece_indices):
    return all(0 <= i < self.num_rows and 0 <= j < self.num_columns
               for i, j in temp_active_piece_indices)

Ten fragment kodu jest bardzo krótki i zwarty. Kilka wierszy i używanie rzeczy Pythona, takich jak podwójne rozumienie list. Wystarczy kilka bajtów kodu.

Trudno mi to jednak czytać i nie chciałbym współpracować z tobą nad tym projektem, gdybyś napisał dużo takiego kodu, ponieważ musiałbym spędzić tyle czasu na rozplątywaniu i próbowaniu zrozumienia, co coś robi, przed modyfikacją lub przedłużyć. A jeśli trzeba go zmodyfikować, może wymagać całkowitego przepisania.

Sugestia, jak napisać to inaczej, jest dla mnie łatwiejsza do zrozumienia (znowu jest to oczywiście subiektywne).

def in_bounds(self, x, y):
    return x >= 0 and x < self.num_columns and y >= 0 and y < self.num_rows
        

Ta funkcja obsługuje tylko jeden element i pobiera współrzędne bezpośrednio, nie "obchodzi", co te współrzędne reprezentują (kawałek, ale można go teraz użyć również do czegoś innego).

Myślę, że używanie xi yjest naturalne, ponieważ wiersze i kolumny działają jako współrzędne w grze tetris. Jest to bardziej szczegółowe niż ii jktóre są często używane jako iteratory dla dowolnej pętli wewnętrznej, która niekoniecznie lub naturalnie odnosi się do współrzędnych.

Poza tym patrzę na każdy przypadek po kolei, nie ma potrzeby używania alli „zapamiętywania” elementów jednocześnie. Jest to najważniejsza zmiana, aby była czytelna i łatwa do zrozumienia kawałek po kawałku.

W przypadku wywoływania funkcji z zewnątrz ma to sens all.

if all([in_bounds(x,y) for (y,x) in pieces]):

2

def translate_active_piece(self, direction):
    if direction == 'right':
        x, y = 0, 1
    elif direction == 'left':
        x, y = 0, -1
    elif direction == 'down':
        x, y = 1, 0

To jest odwrotnie. Nikt nigdy nie zrozumie źle tego kodu ani nie będzie musiał patrzeć dłużej niż kilka sekund, aby go zrozumieć. Wolę taki kod, kiedy coś sprawdzam. Można go przepisać i zagmatwać, aby był znacznie krótszy, ale nie jest to konieczne i nie ułatwiłoby pracy.

Gdybyś miał więcej opcji, powiedzmy 4 lub więcej, użyłbym dyktu, aby zmniejszyć liczbę powtórzeń if elifix,y=

3

    curses.init_pair(1, curses.COLOR_RED, curses.COLOR_BLACK)
    curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK)
    curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK)
    curses.init_pair(4, curses.COLOR_BLUE, curses.COLOR_BLACK)
    curses.init_pair(5, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
    curses.init_pair(6, curses.COLOR_CYAN, curses.COLOR_BLACK)
    curses.init_pair(7, curses.COLOR_WHITE, curses.COLOR_BLACK)

Za dużo powtórzeń. Można to poprawić.

colors = [curses.COLOR_RED, curses.COLOR_GREEN, curses.COLOR_YELLOW, ...]
for i, x in enumerate(colors, start=1):
    curses.init_pair(i, x, curses.COLOR_BLACK)