Python 3 Curses Terminal Tetris
J'apprends la programmation à moi depuis quelques années principalement en utilisant python, et j'ai créé un jeu de ligne de commande de type tetris en utilisant la bibliothèque Curses de Python. Le jeu semble fonctionner comme prévu; mais je manque d'expérience dans l'écriture de code orienté objet. Donc, j'aimerais avoir des commentaires sur la structure du code, et en supposant que la structure est correcte, le style du code.
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()
Réponses
En général, il semble structuré, utilisant de bons noms, divisé en fonctions, etc. Ce sont de bonnes choses.
J'ai quelques commentaires mais rappelez-vous que ce sont mes opinions, je ne peux pas dire que c'est vrai ou faux et je ne ferai référence à aucun style standard ou de code.
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)
Ce morceau de code est très court et compact. Quelques lignes et en utilisant des choses Python comme la compréhension de double liste Cela fait beaucoup avec quelques octets de code.
J'ai du mal à lire cependant et je ne voudrais pas coopérer avec vous sur ce projet si vous écriviez beaucoup de code comme celui-ci, car je devrais passer tellement de temps à démêler et à essayer de comprendre ce que fait quelque chose, avant de modifier ou en l'étendant. Et s'il doit être modifié, il peut être nécessaire de le réécrire complètement.
Une suggestion pour l'écrire différemment, que je trouve plus facile à comprendre (encore une fois, c'est subjectif bien sûr).
def in_bounds(self, x, y):
return x >= 0 and x < self.num_columns and y >= 0 and y < self.num_rows
Cette fonction ne gère qu'une seule pièce et elle prend les coordonnées directement, elle ne "se soucie" pas de ce que ces coordonnées représentent (une pièce, mais pourrait maintenant être utilisée pour autre chose aussi).
Je pense que l'utilisation de xet yest naturelle puisque les lignes et les colonnes fonctionnent comme des coordonnées dans un jeu tetris. Ceci est plus spécifique que iet jqui sont souvent utilisés comme itérateurs pour toute boucle interne, qui ne se rapporte pas nécessairement ou naturellement aux coordonnées.
Aussi, je regarde chaque cas un à la fois, pas besoin d'utiliser allet de "se souvenir" des pièces simultanément. Il s'agit du changement le plus important pour le rendre lisible et facile à comprendre pièce par pièce.
Lorsque vous appelez la fonction de l'extérieur, il est désormais logique de l'utiliser 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
C'est le contraire. Personne ne comprendra jamais mal ce code ni ne devra regarder plus de quelques secondes pour le comprendre. Je préfère un code comme celui-ci lorsque je passe en revue quelque chose. Il pourrait être réécrit et alambiqué pour être beaucoup plus court, mais ce n'est pas nécessaire et cela ne faciliterait pas le travail.
Si vous aviez plus d'options, disons 4 ou plus, j'utiliserais un dict pour réduire la répétition de if elifetx,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)
Trop de répétition ici. Cela peut être amélioré.
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)