Python 3 Curses Terminal Tetris
Tenho aprendido a programar por conta própria há alguns anos, principalmente usando python, e criei um jogo de linha de comando tipo tetris usando a biblioteca Curses do Python. O jogo parece funcionar como planejado; mas não tenho experiência em escrever código orientado a objetos. Portanto, gostaria de receber um feedback sobre a estrutura do código e, presumindo que a estrutura esteja ok, o estilo do código.
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()
Respostas
Em geral, parece estruturado, usando bons nomes, dividido em funções, etc. Essas são coisas boas.
Tenho alguns comentários, mas lembre-se que essas são minhas opiniões, não posso dizer se está certo ou errado e não vou me referir a nenhum padrão ou estilo de código.
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)
Este código é muito curto e compacto. Poucas linhas e usando Python coisas como compreensão de lista dupla. É muito executado com poucos bytes de código.
No entanto, acho difícil de ler e não gostaria de cooperar com você neste projeto se você escrevesse muito código como este, porque eu teria que gastar muito tempo desembaraçando e tentando entender o que algo faz, antes de modificar ou estendê-lo. E se precisar ser modificado, pode ser necessário reescrever completamente.
Uma sugestão de como escrever de forma diferente, que acho mais fácil de entender (de novo, isso é subjetivo, claro).
def in_bounds(self, x, y):
return x >= 0 and x < self.num_columns and y >= 0 and y < self.num_rows
Esta função lida apenas com uma peça e leva as coordenadas diretamente, não "importa" o que essas coordenadas representam (uma peça, mas agora poderia ser usada para qualquer outra coisa).
Acho que usar xe yé natural, já que as linhas e colunas funcionam como coordenadas em um jogo de tetris. Isso é mais específico do que ie jfrequentemente usado como iterador para qualquer loop interno, que não necessariamente ou naturalmente se relaciona com as coordenadas.
Além disso, estou olhando para cada caso, um de cada vez, não há necessidade de usar alle "lembrar" as peças simultaneamente. Esta é a mudança mais importante para torná-lo legível e fácil de entender peça por peça.
Ao chamar a função de fora, agora faz sentido usá-la 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
Isso é o oposto. Ninguém jamais interpretará mal este código ou terá que olhar mais do que alguns segundos para entendê-lo. Eu prefiro um código como este quando faço uma revisão. Ele poderia ser reescrito e complicado para ser muito mais curto, mas isso não é necessário e não tornaria mais fácil trabalhar com ele.
Se você tivesse mais opções, digamos 4 ou mais, eu usaria um dict para reduzir a repetição de if elifex,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)
Muita repetição aqui. Isso pode ser melhorado.
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)