diff --git a/Tetris2000.py b/Tetris2000.py index bd9e412..b5c26dc 100644 --- a/Tetris2000.py +++ b/Tetris2000.py @@ -9,1528 +9,14 @@ Parts of comments issued from 2009 Tetris Design Guideline """ -import ctypes -import collections -import itertools -import locale -import os -import time import sys -import consts -from consts import L, R, CLOCKWISE, COUNTERCLOCKWISE -from qt5 import QtWidgets, QtCore, QtGui, QtMultimedia -from __version__ import __title__, __author__, __version__ -from point import Point -from tetromino import Block, Tetromino, GhostPiece +from source.qt5 import QtWidgets +from source.game_gui import Window -class Grid(QtWidgets.QWidget): - """ - Mother class of Hold queue, Matrix, Next piece, and Next queue - """ - - ROWS = consts.GRID_DEFAULT_ROWS + consts.GRID_INVISIBLE_ROWS - COLUMNS = consts.GRID_DEFAULT_COLUMNS - STARTING_POSITION = Point( - consts.GRID_DEFAULT_COLUMNS // 2, - consts.GRID_DEFAULT_ROWS // 2 + consts.GRID_INVISIBLE_ROWS, - ) - GRIDLINE_COLOR = consts.GRID_GRIDLINE_COLOR - HARD_DROP_MOVEMENT = consts.GRID_HARD_DROP_MOVEMENT - SPOTLIGHT = Point(*consts.GRID_SPOTLIGHT) - - def __init__(self, frames): - super().__init__(frames) - self.setStyleSheet("background-color: transparent") - self.frames = frames - self.spotlight = self.SPOTLIGHT - self.piece = None - - def insert(self, piece, position=None): - """ - Add a Tetromino to self - Update its coordinates - """ - piece.insert_into(self, position or self.STARTING_POSITION) - self.piece = piece - self.update() - - def resizeEvent(self, event): - self.bottom = Block.side * self.ROWS - self.grid_top = consts.GRID_INVISIBLE_ROWS * Block.side - width = Block.side * self.COLUMNS - self.left = (self.width() - width) // 2 - self.right = width + self.left - self.top_left_corner = Point(self.left, 0) - - def paintEvent(self, event=None): - painter = QtGui.QPainter(self) - painter.setRenderHint(QtGui.QPainter.Antialiasing) - - self.paint_grid(painter) - - if (not self.frames.paused or not self.frames.playing) and self.piece: - self.paint_piece(painter, self.piece) - - def paint_grid(self, painter): - painter.setPen(self.GRIDLINE_COLOR) - for x in (self.left + i * Block.side for i in range(self.COLUMNS + 1)): - painter.drawLine(x, self.grid_top, x, self.bottom) - for y in ( - j * Block.side for j in range(consts.GRID_INVISIBLE_ROWS, self.ROWS + 1) - ): - painter.drawLine(self.left, y, self.right, y) - - def paint_piece(self, painter, piece): - for mino in piece.minoes: - mino.paint(painter, self.top_left_corner, self.spotlight) - - -class Matrix(Grid): - """ - The rectangular arrangement of cells creating the active game area. - Tetriminos fall from the top-middle just above the Skyline (off-screen) to the bottom. - """ - - ROWS = consts.MATRIX_ROWS + consts.GRID_INVISIBLE_ROWS - COLUMNS = consts.MATRIX_COLUMNS - STARTING_POSITION = Point(COLUMNS // 2 - 1, consts.GRID_INVISIBLE_ROWS - 1) - TEXT_COLOR = consts.MATRIX_TEXT_COLOR - - drop_signal = QtCore.Signal(int) - lock_signal = QtCore.Signal(int, str) - - def __init__(self, frames): - super().__init__(frames) - - self.load_sfx() - - self.game_over = False - self.text = "" - self.temporary_texts = [] - - self.setFocusPolicy(QtCore.Qt.StrongFocus) - - self.auto_repeat_delay = 0 - self.auto_repeat_timer = QtCore.QTimer() - self.auto_repeat_timer.setTimerType(QtCore.Qt.PreciseTimer) - self.auto_repeat_timer.timeout.connect(self.auto_repeat) - self.fall_timer = QtCore.QTimer() - self.fall_timer.timeout.connect(self.fall) - - self.cells = [] - - def load_sfx(self): - self.wall_sfx = QtMultimedia.QSoundEffect(self) - url = QtCore.QUrl.fromLocalFile(consts.WALL_SFX_PATH) - self.wall_sfx.setSource(url) - - self.rotate_sfx = QtMultimedia.QSoundEffect(self) - url = QtCore.QUrl.fromLocalFile(consts.ROTATE_SFX_PATH) - self.rotate_sfx.setSource(url) - - self.hard_drop_sfx = QtMultimedia.QSoundEffect(self) - url = QtCore.QUrl.fromLocalFile(consts.HARD_DROP_SFX_PATH) - self.hard_drop_sfx.setSource(url) - - def new_game(self): - self.game_over = False - self.lock_delay = consts.LOCK_DELAY - self.cells = [self.empty_row() for y in range(self.ROWS)] - self.setFocus() - self.actions_to_repeat = [] - self.wall_hit = False - - def new_level(self, level): - self.show_temporary_text(self.tr("Level\n") + str(level)) - self.speed = consts.INITIAL_SPEED * (0.8 - ((level - 1) * 0.007)) ** (level - 1) - self.fall_timer.start(self.speed) - if level > 15: - self.lock_delay = consts.LOCK_DELAY * (consts.AFTER_LVL_15_ACCELERATION ** (level-15)) - - def empty_row(self): - return [None for x in range(self.COLUMNS)] - - def is_empty_cell(self, coord): - x, y = coord.x, coord.y - return ( - 0 <= x < self.COLUMNS - and y < self.ROWS - and not (0 <= y and self.cells[y][x]) - ) - - def keyPressEvent(self, event): - if event.isAutoRepeat(): - return - - if not self.frames.playing: - return - - try: - action = self.keys[event.key()] - except KeyError: - return - - self.do(action) - if action in (s.MOVE_LEFT, s.MOVE_RIGHT, s.SOFT_DROP): - if action not in self.actions_to_repeat: - self.auto_repeat_wait() - self.actions_to_repeat.append(action) - - def keyReleaseEvent(self, event): - if event.isAutoRepeat(): - return - - if not self.frames.playing: - return - - try: - self.actions_to_repeat.remove(self.keys[event.key()]) - except (KeyError, ValueError): - pass - else: - self.auto_repeat_wait() - - if not self.actions_to_repeat: - for mino in self.piece.minoes: - mino.fade() - self.update() - - def auto_repeat_wait(self): - self.auto_repeat_delay = ( - time.time() + settings[s.DELAYS][s.AUTO_SHIFT_DELAY] / 1000 - ) - - def auto_repeat(self): - """ - Tapping the move button allows a single cell movement of the Tetrimino - in the direction pressed. - Holding down the move button triggers an Auto-Repeat movement - that allows the player to move a Tetrimino from one side of the Matrix - to the other in about 0.5 seconds. - This is essential on higher levels when the Fall Speed of a Tetrimino is very fast. - There is a slight delay between the time the move button is pressed - and the time when Auto-Repeat kicks in : s.AUTO_SHIFT_DELAY. - This delay prevents unwanted extra movement of a Tetrimino. - Auto-Repeat only affects Left/Right movement. - Auto-Repeat continues to the Next Tetrimino (after Lock Down) - as long as the move button remains pressed. - In addition, when Auto-Repeat begins, - and the player then holds the opposite direction button, - the Tetrimino then begins moving the opposite direction with the initial delay. - When any single button is then released, - the Tetrimino should again move in the direction still held, - with the Auto-Shift delay applied once more. - """ - if ( - not self.frames.playing - or self.frames.paused - or time.time() < self.auto_repeat_delay - ): - return - - if self.actions_to_repeat: - self.do(self.actions_to_repeat[-1]) - - def do(self, action): - """The player can move, rotate, Soft Drop, Hard Drop, - and Hold the falling Tetrimino (i.e., the Tetrimino in play). - """ - if action == s.PAUSE: - self.frames.pause(not self.frames.paused) - - if not self.frames.playing or self.frames.paused or not self.piece: - return - - for mino in self.piece.minoes: - mino.shine(0) - - if action == s.MOVE_LEFT: - if self.piece.move(L, 0): - self.lock_wait() - self.wall_hit = False - elif not self.wall_hit: - self.wall_hit = True - self.wall_sfx.play() - - elif action == s.MOVE_RIGHT: - if self.piece.move(R, 0): - self.rotate_sfx.play() - self.lock_wait() - self.wall_hit = False - elif not self.wall_hit: - self.wall_hit = True - self.wall_sfx.play() - - elif action == s.ROTATE_CLOCKWISE: - if self.piece.rotate(direction=CLOCKWISE): - self.rotate_sfx.play() - self.lock_wait() - self.wall_hit = False - elif not self.wall_hit: - self.wall_hit = True - self.wall_sfx.play() - - elif action == s.ROTATE_COUNTERCLOCKWISE: - if self.piece.rotate(direction=COUNTERCLOCKWISE): - self.lock_wait() - self.wall_hit = False - elif not self.wall_hit: - self.wall_hit = True - self.wall_sfx.play() - - elif action == s.SOFT_DROP: - if self.piece.soft_drop(): - self.drop_signal.emit(1) - self.wall_hit = False - elif not self.wall_hit: - self.wall_hit = True - self.wall_sfx.play() - - elif action == s.HARD_DROP: - trail = self.piece.hard_drop() - self.top_left_corner += Point(0, self.HARD_DROP_MOVEMENT * Block.side) - self.drop_signal.emit(2 * trail) - QtCore.QTimer.singleShot(consts.ANIMATION_DELAY, self.after_hard_drop) - self.hard_drop_sfx.play() - self.lock_phase() - - elif action == s.HOLD: - self.frames.hold() - - def after_hard_drop(self): - """ Reset the animation movement of the Matrix on a hard drop """ - self.top_left_corner -= Point(0, self.HARD_DROP_MOVEMENT * Block.side) - - def lock_wait(self): - self.fall_delay = time.time() + (self.speed + self.lock_delay) / 1000 - - def fall(self): - """ - Once a Tetrimino is generated, - it immediately drops one row (if no existing Block is in its path). - From here, it begins its descent to the bottom of the Matrix. - The Tetrimino will fall at its normal Fall Speed - whether or not it is being manipulated by the player. - """ - if self.piece: - if self.piece.move(0, 1): - self.lock_wait() - else: - if time.time() >= self.fall_delay: - self.lock_phase() - - def lock_phase(self): - """ - The player can perform the same actions on a Tetrimino in this phase - as he/she can in the Falling Phase, - as long as the Tetrimino is not yet Locked Down. - A Tetrimino that is Hard Dropped Locks Down immediately. - However, if a Tetrimino naturally falls or Soft Drops onto a landing Surface, - it is given a delay (self.fall_delay) on a Lock Down Timer - before it actually Locks Down. - """ - - self.wall_sfx.play() - - # Enter minoes into the matrix - for mino in self.piece.minoes: - if mino.coord.y >= 0: - self.cells[mino.coord.y][mino.coord.x] = mino - mino.shine(glowing=2, delay=consts.ANIMATION_DELAY) - QtCore.QTimer.singleShot(consts.ANIMATION_DELAY, self.update) - self.update() - - if all( - mino.coord.y < consts.GRID_INVISIBLE_ROWS for mino in self.piece.minoes - ): - self.frames.game_over() - return - - """ - In this phase, - the engine looks for patterns made from Locked Down Blocks in the Matrix. - Once a pattern has been matched, - it can trigger any number of Tetris variant-related effects. - The classic pattern is the Line Clear pattern. - This pattern is matched when one or more rows of 10 horizontally aligned - Matrix cells are occupied by Blocks. - The matching Blocks are then marked for removal on a hit list. - Blocks on the hit list are cleared from the Matrix at a later time - in the Eliminate Phase. - """ - # Dectect complete lines - self.complete_lines = [] - for y, row in enumerate(self.cells): - if all(cell for cell in row): - self.complete_lines.append(y) - for block in row: - block.shine() - self.spotlight = row[self.COLUMNS // 2].coord - self.auto_repeat_timer.stop() - self.lock_signal.emit(len(self.complete_lines), self.piece.t_spin) - - if self.complete_lines: - self.fall_timer.stop() - QtCore.QTimer.singleShot(consts.LINE_CLEAR_DELAY, self.eliminate_phase) - else: - self.frames.new_piece() - - def eliminate_phase(self): - """ - Any Minos marked for removal, i.e., on the hit list, - are cleared from the Matrix in this phase. - If this results in one or more complete 10-cell rows in the Matrix - becoming unoccupied by Minos, - then all Minos above that row(s) collapse, - or fall by the number of complete rows cleared from the Matrix. - """ - for y in self.complete_lines: - del self.cells[y] - self.cells.insert(0, self.empty_row()) - - for y, row in enumerate(self.cells): - for x, block in enumerate(row): - if block: - block.coord.setX(x) - block.coord.setY(y) - - self.update() - self.auto_repeat_wait() - self.auto_repeat_timer.start(settings[s.DELAYS][s.AUTO_REPEAT_RATE]) - - self.frames.new_piece() - - def paintEvent(self, event): - """ - Draws grid, actual piece, blocks in the Matrix and show texts - """ - painter = QtGui.QPainter(self) - painter.setRenderHint(QtGui.QPainter.Antialiasing) - - self.paint_grid(painter) - - if not self.frames.paused or self.game_over: - if self.piece: - if settings[s.OTHER][s.GHOST]: - self.ghost = GhostPiece(self.piece) - self.spotlight = self.ghost.minoes[0].coord - self.paint_piece(painter, self.ghost) - self.paint_piece(painter, self.piece) - - # Blocks in matrix - for row in self.cells: - for block in row: - if block: - block.paint(painter, self.top_left_corner, self.spotlight) - - if self.frames.playing and self.frames.paused: - painter.setFont(QtGui.QFont("Maassslicer", 0.75 * Block.side)) - painter.setPen(self.TEXT_COLOR) - painter.drawText( - self.rect(), - QtCore.Qt.AlignCenter | QtCore.Qt.TextWordWrap, - self.tr("PAUSE\n\nPress %s\nto resume") % settings[s.KEYBOARD][s.PAUSE], - ) - if self.game_over: - painter.setFont(QtGui.QFont("Maassslicer", Block.side)) - painter.setPen(self.TEXT_COLOR) - painter.drawText( - self.rect(), - QtCore.Qt.AlignCenter | QtCore.Qt.TextWordWrap, - self.tr("GAME\nOVER"), - ) - if self.temporary_texts: - painter.setFont(self.temporary_text_font) - painter.setPen(self.TEXT_COLOR) - painter.drawText( - self.rect(), - QtCore.Qt.AlignHCenter | QtCore.Qt.TextWordWrap, - "\n\n\n" + "\n\n".join(self.temporary_texts), - ) - - def resizeEvent(self, event): - super().resizeEvent(event) - self.temporary_text_font = QtGui.QFont(consts.MATRIX_FONT_NAME, Block.side) - - def show_temporary_text(self, text): - self.temporary_texts.append(text.upper()) - self.font = self.temporary_text_font - self.update() - QtCore.QTimer.singleShot(consts.TEMPORARY_TEXT_DURATION, self.delete_text) - - def delete_text(self): - del self.temporary_texts[0] - self.update() - - def focusOutEvent(self, event): - if self.frames.playing: - self.frames.pause(True) - - -class HoldQueue(Grid): - """ - The Hold Queue allows the player to “hold” a falling Tetrimino for as long as they wish. - Holding a Tetrimino releases the Tetrimino already in the Hold Queue (if one exists). - """ - - def paintEvent(self, event): - if not settings[s.OTHER][s.HOLD_ENABLED]: - return - - super().paintEvent(event) - - -class NextQueue(Grid): - """ - The Next Queue allows the player to see the Next Tetrimino that will be generated - and put into play. - """ - - ROWS = consts.NEXT_QUEUE_ROWS - COLUMNS = consts.NEXT_QUEUE_COLUMNS - - def __init__(self, parent): - super().__init__(parent) - self.pieces = [] - - def new_piece(self): - self.pieces = self.pieces[1:] + [Tetromino()] - self.insert_pieces() - - def insert_pieces(self): - for y, piece in enumerate(self.pieces): - piece.insert_into(self, Point(3, 3 * y + 1)) - - def paintEvent(self, event=None): - if not settings[s.OTHER][s.SHOW_NEXT_QUEUE]: - return - - painter = QtGui.QPainter(self) - painter.setRenderHint(QtGui.QPainter.Antialiasing) - - if not self.frames.paused: - for piece in self.pieces: - self.paint_piece(painter, piece) - - -class AspectRatioWidget(QtWidgets.QWidget): - """ - Keeps aspect ratio of child widget on resize - https://stackoverflow.com/questions/48043469/how-to-lock-aspect-ratio-while-resizing-the-window - """ - - def __init__(self, widget, parent): - super().__init__(parent) - self.aspect_ratio = widget.size().width() / widget.size().height() - self.setLayout(QtWidgets.QBoxLayout(QtWidgets.QBoxLayout.LeftToRight, self)) - # add spacer, then widget, then spacer - self.layout().addItem(QtWidgets.QSpacerItem(0, 0)) - self.layout().addWidget(widget) - self.layout().addItem(QtWidgets.QSpacerItem(0, 0)) - - def resizeEvent(self, e): - w = e.size().width() - h = e.size().height() - - if w / h > self.aspect_ratio: # too wide - self.layout().setDirection(QtWidgets.QBoxLayout.LeftToRight) - widget_stretch = h * self.aspect_ratio - outer_stretch = (w - widget_stretch) / 2 + 0.5 - else: # too tall - self.layout().setDirection(QtWidgets.QBoxLayout.TopToBottom) - widget_stretch = w / self.aspect_ratio - outer_stretch = (h - widget_stretch) / 2 + 0.5 - - self.layout().setStretch(0, outer_stretch) - self.layout().setStretch(1, widget_stretch) - self.layout().setStretch(2, outer_stretch) - - -class Stats(QtWidgets.QWidget): - """ - Show informations relevant to the game being played is displayed on-screen. - Looks for patterns made from Locked Down Blocks in the Matrix and calculate score. - """ - - ROWS = consts.STATS_ROWS - COLUMNS = consts.STATS_COLUMNS - TEXT_COLOR = consts.STATS_TEXT_COLOR - - temporary_text = QtCore.Signal(str) - - def __init__(self, frames): - super().__init__(frames) - self.frames = frames - self.setStyleSheet("background-color: transparent") - - self.load_sfx() - - self.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding - ) - self.text_options = QtGui.QTextOption(QtCore.Qt.AlignRight) - self.text_options.setWrapMode(QtGui.QTextOption.WrapAtWordBoundaryOrAnywhere) - - self.clock = QtCore.QTimer() - self.clock.timeout.connect(self.tick) - - self.high_score = int(qsettings.value(self.tr("High score"), 0)) - - def load_sfx(self): - self.line_clear_sfx = QtMultimedia.QSoundEffect(self) - url = QtCore.QUrl.fromLocalFile(consts.LINE_CLEAR_SFX_PATH) - self.line_clear_sfx.setSource(url) - - self.tetris_sfx = QtMultimedia.QSoundEffect(self) - url = QtCore.QUrl.fromLocalFile(consts.TETRIS_SFX_PATH) - self.tetris_sfx.setSource(url) - - def new_game(self): - self.level -= 1 - self.goal = 0 - self.complete_lines_total = 0 - self.score_total = 1 - self.t_spin_total = 0 - self.mini_t_spin_total = 0 - self.nb_back_to_back = 0 - self.back_to_back_scores = None - self.combo = -1 - self.combos_total = 0 - self.max_combo = 0 - self.chronometer = 0 - self.nb_tetro = 0 - self.clock.start(1000) - self.lines_stats = [0, 0, 0, 0, 0] - - def new_level(self): - self.level += 1 - self.goal += 5 * self.level - return self.level - - def update_score(self, nb_complete_lines, t_spin): - """ - The player scores points by performing Single, Double, Triple, - and Tetris Line Clears, as well as T-Spins and Mini T-Spins. - Soft and Hard Drops also award points. - There is a special bonus for Back-to-Backs, - which is when two actions such as a Tetris and T-Spin Double take place - without a Single, Double, or Triple Line Clear occurring between them. - Scoring for Line Clears, T-Spins, and Mini T-Spins are level dependent, - while Hard and Soft Drop point values remain constant. - """ - self.nb_tetro += 1 - if nb_complete_lines: - self.complete_lines_total += nb_complete_lines - self.lines_stats[nb_complete_lines] += 1 - if t_spin == "T-Spin": - self.t_spin_total += 1 - elif t_spin == "Mini T-Spin": - self.mini_t_spin_total += 1 - - score = consts.SCORES[nb_complete_lines][t_spin] - - if score: - text = " ".join((t_spin, consts.SCORES[nb_complete_lines]["name"])) - if (t_spin and nb_complete_lines) or nb_complete_lines == 4: - self.tetris_sfx.play() - elif t_spin or nb_complete_lines: - self.line_clear_sfx.play() - - self.goal -= score - score = 100 * self.level * score - self.score_total += score - - self.temporary_text.emit(text + "\n{:n}".format(score)) - - # ============================================================================== - # Combo - # Bonus for complete lines on each consecutive lock downs - # if nb_complete_lines: - # ============================================================================== - if nb_complete_lines: - self.combo += 1 - if self.combo > 0: - if nb_complete_lines == 1: - combo_score = 20 * self.combo * self.level - else: - combo_score = 50 * self.combo * self.level - self.score_total += combo_score - self.max_combo = max(self.max_combo, self.combo) - self.combos_total += 1 - self.temporary_text.emit( - self.tr("COMBO x{:n}\n{:n}").format(self.combo, combo_score) - ) - else: - self.combo = -1 - - # ============================================================================== - # Back-to_back sequence - # Two major bonus actions, such as two Tetrises, performed without - # a Single, Double, or Triple Line Clear occurring between them. - # Bonus for Tetrises, T-Spin Line Clears, and Mini T-Spin Line Clears - # performed consecutively in a B2B sequence. - # ============================================================================== - if (t_spin and nb_complete_lines) or nb_complete_lines == 4: - if self.back_to_back_scores is not None: - self.back_to_back_scores.append(score // 2) - else: - # The first Line Clear in the Back-to-Back sequence - # does not receive the Back-to-Back Bonus. - self.back_to_back_scores = [] - elif nb_complete_lines and not t_spin: - # A Back-to-Back sequence is only broken by a Single, Double, or Triple Line Clear. - # Locking down a Tetrimino without clearing a line - # or holding a Tetrimino does not break the Back-to-Back sequence. - # T-Spins and Mini T-Spins that do not clear any lines - # do not receive the Back-to-Back Bonus; instead they are scored as normal. - # They also cannot start a Back-to-Back sequence, however, - # they do not break an existing Back-to-Back sequence. - if self.back_to_back_scores: - b2b_score = sum(self.back_to_back_scores) - self.score_total += b2b_score - self.nb_back_to_back += 1 - self.temporary_text.emit( - self.tr("BACK TO BACK\n{:n}").format(b2b_score) - ) - self.back_to_back_scores = None - - self.high_score = max(self.score_total, self.high_score) - self.update() - - def update_drop_score(self, n): - """ Tetrimino is Soft Dropped for n lines or Hard Dropped for (n/2) lines""" - self.score_total += n - self.high_score = max(self.score_total, self.high_score) - self.update() - - def tick(self): - self.chronometer += 1 - self.update() - - def paintEvent(self, event): - if not self.frames.playing and not self.frames.matrix.game_over: - return - - painter = QtGui.QPainter(self) - painter.setFont(self.font) - painter.setPen(self.TEXT_COLOR) - - painter.drawText( - QtCore.QRectF(self.rect()), self.text(sep="\n\n"), self.text_options - ) - - def text(self, full_stats=False, sep="\n"): - text = ( - self.tr("Score: ") - + locale.format("%i", self.score_total, grouping=True, monetary=True) - + sep - + self.tr("High score: ") - + locale.format("%i", self.high_score, grouping=True, monetary=True) - + sep - + self.tr("Time: {}\n").format( - time.strftime("%H:%M:%S", time.gmtime(self.chronometer)) - ) - + sep - + self.tr("Level: ") - + locale.format("%i", self.level, grouping=True, monetary=True) - + sep - + self.tr("Goal: ") - + locale.format("%i", self.goal, grouping=True, monetary=True) - + sep - + self.tr("Lines: ") - + locale.format( - "%i", self.complete_lines_total, grouping=True, monetary=True - ) - + sep - + self.tr("Mini T-Spins: ") - + locale.format("%i", self.mini_t_spin_total, grouping=True, monetary=True) - + sep - + self.tr("T-Spins: ") - + locale.format("%i", self.t_spin_total, grouping=True, monetary=True) - + sep - + self.tr("Back-to-back: ") - + locale.format("%i", self.nb_back_to_back, grouping=True, monetary=True) - + sep - + self.tr("Max combo: ") - + locale.format("%i", self.max_combo, grouping=True, monetary=True) - + sep - + self.tr("Combos: ") - + locale.format("%i", self.combos_total, grouping=True, monetary=True) - ) - if full_stats: - minutes = self.chronometer / 60 - text += ( - "\n" - + sep - + self.tr("Lines per minute: {:.1f}").format( - self.complete_lines_total / minutes - ) - + sep - + self.tr("Tetrominos locked down: ") - + locale.format("%i", self.nb_tetro, grouping=True, monetary=True) - + sep - + self.tr("Tetrominos per minute: {:.1f}").format( - self.nb_tetro / minutes - ) - + sep - ) - text += sep.join( - score_type["name"] - + self.tr(": ") - + locale.format("%i", nb, grouping=True, monetary=True) - for score_type, nb in tuple(zip(consts.SCORES, self.lines_stats))[1:] - ) - return text - - def resizeEvent(self, event): - self.font = QtGui.QFont(consts.STATS_FONT_NAME, Block.side / 3.5) - - -class Frames(QtWidgets.QWidget): - """ - Display Hold queue, Matrix, Next piece, Next queue and Stats. - Manage interactions between them. - """ - - def __init__(self, parent): - super().__init__(parent) - self.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding - ) - - self.playing = False - self.paused = False - self.load_music() - - self.hold_queue = HoldQueue(self) - self.matrix = Matrix(self) - self.next_piece = Grid(self) - self.stats = Stats(self) - self.next_queue = NextQueue(self) - - self.matrices = (self.hold_queue, self.matrix, self.next_piece) - self.columns = sum(matrix.COLUMNS + 2 for matrix in self.matrices) - self.rows = self.matrix.ROWS + consts.GRID_INVISIBLE_ROWS - - w = QtWidgets.QWidget(self) - w.setStyleSheet("background-color: transparent") - grid = QtWidgets.QGridLayout() - for x in range(self.rows): - grid.setRowStretch(x, 1) - for y in range(self.columns): - grid.setColumnStretch(y, 1) - grid.setSpacing(0) - x, y = 0, 0 - grid.addWidget( - self.hold_queue, y, x, self.hold_queue.ROWS + 1, self.hold_queue.COLUMNS + 2 - ) - x += self.hold_queue.COLUMNS + 2 - grid.addWidget( - self.matrix, - y, - x, - self.matrix.ROWS + consts.GRID_INVISIBLE_ROWS, - self.matrix.COLUMNS + 2, - ) - x += self.matrix.COLUMNS + 3 - grid.addWidget( - self.next_piece, y, x, self.next_piece.ROWS + 1, self.next_piece.COLUMNS + 2 - ) - x, y = 0, self.hold_queue.ROWS + 1 - grid.addWidget(self.stats, y + 1, x, self.stats.ROWS, self.stats.COLUMNS + 1) - x += self.stats.COLUMNS + self.matrix.COLUMNS + 5 - grid.addWidget( - self.next_queue, y, x, self.next_queue.ROWS, self.next_queue.COLUMNS + 2 - ) - w.setLayout(grid) - w.resize(self.columns, self.rows) - asw = AspectRatioWidget(w, self) - layout = QtWidgets.QGridLayout() - layout.addWidget(asw) - self.setLayout(layout) - - self.stats.temporary_text.connect(self.matrix.show_temporary_text) - self.matrix.drop_signal.connect(self.stats.update_drop_score) - self.matrix.lock_signal.connect(self.stats.update_score) - - self.set_background( - os.path.join(consts.BG_IMAGE_DIR, consts.START_BG_IMAGE_NAME) - ) - - self.apply_settings() - - def load_music(self): - playlist = QtMultimedia.QMediaPlaylist(self) - for entry in os.scandir(consts.MUSICS_DIR): - path = os.path.join(consts.MUSICS_DIR, entry.name) - url = QtCore.QUrl.fromLocalFile(path) - music = QtMultimedia.QMediaContent(url) - playlist.addMedia(music) - playlist.setPlaybackMode(QtMultimedia.QMediaPlaylist.Loop) - self.music = QtMultimedia.QMediaPlayer(self) - self.music.setAudioRole(QtMultimedia.QAudio.GameRole) - self.music.setPlaylist(playlist) - self.music.setVolume(settings[s.SOUND][s.MUSIC_VOLUME]) - - def apply_settings(self): - if self.music.volume() > 5 and self.playing: - self.music.play() - else: - self.music.pause() - - if self.playing: - self.hold_enabled = settings[s.OTHER][s.HOLD_ENABLED] - self.pause(False) - - self.matrix.keys = { - getattr(QtCore.Qt, "Key_" + name): action - for action, name in settings[s.KEYBOARD].items() - } - self.matrix.auto_repeat_timer.start(settings[s.DELAYS][s.AUTO_REPEAT_RATE]) - self.matrix.spotlight = Matrix.SPOTLIGHT - - for sfx in ( - self.matrix.rotate_sfx, - self.matrix.wall_sfx, - self.stats.line_clear_sfx, - self.stats.tetris_sfx, - ): - sfx.setVolume(settings[s.SOUND][s.SFX_VOLUME]) - - def resizeEvent(self, event): - Block.side = 0.9 * min(self.width() // self.columns, self.height() // self.rows) - self.resize_bg_image() - - def reset_backgrounds(self): - backgrounds_paths = ( - os.path.join(consts.BG_IMAGE_DIR, entry.name) - for entry in os.scandir(consts.BG_IMAGE_DIR) - ) - self.backgrounds_cycle = itertools.cycle(backgrounds_paths) - - def set_background(self, path): - self.bg_image = QtGui.QImage(path) - self.resize_bg_image() - - def resize_bg_image(self): - self.resized_bg_image = QtGui.QPixmap.fromImage(self.bg_image) - self.resized_bg_image = self.resized_bg_image.scaled( - self.size(), - QtCore.Qt.KeepAspectRatioByExpanding, - QtCore.Qt.SmoothTransformation, - ) - self.resized_bg_image = self.resized_bg_image.copy( - (self.resized_bg_image.width() - self.width()) // 2, - (self.resized_bg_image.height() - self.height()) // 2, - self.width(), - self.height(), - ) - self.update() - - def paintEvent(self, event): - painter = QtGui.QPainter(self) - painter.drawPixmap(self.rect(), self.resized_bg_image) - - def new_game(self): - if self.playing: - answer = QtWidgets.QMessageBox.question( - self, - self.tr("New game"), - self.tr("A game is in progress.\n" "Do you want to abord it?"), - QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, - QtWidgets.QMessageBox.Cancel, - ) - if answer == QtWidgets.QMessageBox.Cancel: - self.pause(False) - return - self.music.stop() - - self.reset_backgrounds() - self.stats.level, ok = QtWidgets.QInputDialog.getInt( - self, - self.tr("New game"), - self.tr("Start level:"), - 1, - 1, - 15, - flags=QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowTitleHint, - ) - if not ok: - return - - self.playing = True - self.load_music() - self.music.play() - self.hold_queue.piece = None - self.stats.new_game() - self.matrix.new_game() - self.next_queue.pieces = [Tetromino() for _ in range(5)] - self.next_queue.insert_pieces() - self.next_piece.insert(Tetromino()) - self.pause(False) - self.new_level() - self.new_piece() - - def new_level(self): - self.set_background(next(self.backgrounds_cycle)) - level = self.stats.new_level() - self.matrix.new_level(level) - - def new_piece(self): - if self.stats.goal <= 0: - self.new_level() - self.matrix.insert(self.next_piece.piece) - self.matrix.lock_wait() - self.next_piece.insert(self.next_queue.pieces[0]) - self.next_queue.new_piece() - self.hold_enabled = settings[s.OTHER][s.HOLD_ENABLED] - self.update() - - if not self.matrix.piece.move(0, 0): - self.game_over() - return - - self.matrix.fall_timer.start(self.matrix.speed) - - def pause(self, paused): - if not self.playing: - return - - if paused: - self.paused = True - self.update() - self.matrix.fall_timer.stop() - self.stats.clock.stop() - self.matrix.auto_repeat_timer.stop() - self.music.pause() - else: - self.matrix.text = "" - self.update() - QtCore.QTimer.singleShot(1000, self.resume) - - def resume(self): - self.paused = False - self.update() - self.matrix.fall_timer.start(self.matrix.speed) - self.stats.clock.start(1000) - self.matrix.auto_repeat_timer.start(settings[s.DELAYS][s.AUTO_REPEAT_RATE]) - self.music.play() - - def hold(self): - """ - Using the Hold command places the Tetrimino in play into the Hold Queue. - The previously held Tetrimino (if one exists) will then start falling - from the top of the Matrix, - beginning from its generation position and North Facing orientation. - Only one Tetrimino may be held at a time. - A Lock Down must take place between Holds. - For example, at the beginning, the first Tetrimino is generated and begins to fall. - The player decides to hold this Tetrimino. - Immediately the Next Tetrimino is generated from the Next Queue and begins to fall. - The player must first Lock Down this Tetrimino before holding another Tetrimino. - In other words, you may not Hold the same Tetrimino more than once. - """ - - if not self.hold_enabled: - return - - piece = self.hold_queue.piece - if piece: - self.hold_queue.insert(self.matrix.piece) - self.matrix.insert(piece) - self.update() - else: - self.hold_queue.insert(self.matrix.piece) - self.new_piece() - self.hold_enabled = False - - def game_over(self): - self.matrix.fall_timer.stop() - self.stats.clock.stop() - self.matrix.auto_repeat_timer.stop() - self.music.stop() - self.playing = False - self.matrix.game_over = True - msgbox = QtWidgets.QMessageBox(self) - msgbox.setWindowTitle(self.tr("Game over")) - msgbox.setIcon(QtWidgets.QMessageBox.Information) - if self.stats.score_total == self.stats.high_score: - msgbox.setText( - self.tr("Congratulations!\nYou have the high score: {}").format( - locale.format( - "%i", self.stats.high_score, grouping=True, monetary=True - ) - ) - ) - qsettings.setValue(self.tr("High score"), self.stats.high_score) - else: - msgbox.setText( - self.tr("Score: {}\nHigh score: {}").format( - locale.format( - "%i", self.stats.score_total, grouping=True, monetary=True - ), - locale.format( - "%i", self.stats.high_score, grouping=True, monetary=True - ), - ) - ) - msgbox.setDetailedText(self.stats.text(full_stats=True)) - # Find and set default the "show details" button - for button in msgbox.buttons(): - if msgbox.buttonRole(button) == QtWidgets.QMessageBox.ActionRole: - msgbox.setDefaultButton(button) - msgbox.exec_() - - -class SettingStrings(QtCore.QObject): - """ - Setting string for translation - """ - - def __init__(self): - super().__init__() - - self.KEYBOARD = self.tr("Keyboard settings") - self.MOVE_LEFT = self.tr("Move left") - self.MOVE_RIGHT = self.tr("Move right") - self.ROTATE_CLOCKWISE = self.tr("Rotate clockwise") - self.ROTATE_COUNTERCLOCKWISE = self.tr("Rotate counterclockwise") - self.SOFT_DROP = self.tr("Soft drop") - self.HARD_DROP = self.tr("Hard drop") - self.HOLD = self.tr("Hold") - self.PAUSE = self.tr("Pause") - self.OTHER = self.tr("Other settings") - - self.DELAYS = self.tr("Delays") - self.AUTO_SHIFT_DELAY = self.tr("Auto-shift delay") - self.AUTO_REPEAT_RATE = self.tr("Auto-repeat rate") - - self.SOUND = self.tr("Sound") - self.MUSIC_VOLUME = self.tr("Music volume") - self.SFX_VOLUME = self.tr("Effects volume") - - self.GHOST = self.tr("Show ghost piece") - self.SHOW_NEXT_QUEUE = self.tr("Show next queue") - self.HOLD_ENABLED = self.tr("Hold enabled") - - -class KeyButton(QtWidgets.QPushButton): - """ Button widget capturing key name on focus """ - - names = { - value: name.replace("Key_", "") - for name, value in QtCore.Qt.__dict__.items() - if "Key_" in name - } - - def __init__(self, *args): - super().__init__(*args) - - def keyPressEvent(self, event): - key = event.key() - self.setText(self.names[key]) - - -class SettingsGroup(QtWidgets.QGroupBox): - """ Group box of a type of settings """ - - def __init__(self, group, parent, cls): - super().__init__(group, parent) - layout = QtWidgets.QFormLayout(self) - self.widgets = {} - for setting, value in settings[group].items(): - if cls == KeyButton: - widget = KeyButton(value) - elif cls == QtWidgets.QCheckBox: - widget = QtWidgets.QCheckBox(setting) - widget.setChecked(value) - elif cls == QtWidgets.QSpinBox: - widget = QtWidgets.QSpinBox() - widget.setRange(0, 1000) - widget.setValue(value) - widget.setSuffix(" ms") - elif cls == QtWidgets.QSlider: - widget = QtWidgets.QSlider(QtCore.Qt.Horizontal) - widget.setValue(value) - if cls == QtWidgets.QCheckBox: - layout.addRow(widget) - else: - layout.addRow(setting, widget) - self.widgets[setting] = widget - self.setLayout(layout) - - -class SettingsDialog(QtWidgets.QDialog): - """ Show settings dialog """ - - def __init__(self, parent): - super().__init__(parent) - self.setWindowTitle(self.tr("Settings")) - self.setModal(True) - - layout = QtWidgets.QGridLayout() - - self.groups = {} - self.groups[s.KEYBOARD] = SettingsGroup(s.KEYBOARD, self, KeyButton) - self.groups[s.DELAYS] = SettingsGroup(s.DELAYS, self, QtWidgets.QSpinBox) - self.groups[s.SOUND] = SettingsGroup(s.SOUND, self, QtWidgets.QSlider) - self.groups[s.OTHER] = SettingsGroup(s.OTHER, self, QtWidgets.QCheckBox) - - layout.addWidget(self.groups[s.KEYBOARD], 0, 0, 3, 1) - layout.addWidget(self.groups[s.DELAYS], 0, 1) - layout.addWidget(self.groups[s.SOUND], 1, 1) - layout.addWidget(self.groups[s.OTHER], 2, 1) - - buttons = QtWidgets.QDialogButtonBox( - QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel - ) - buttons.accepted.connect(self.ok) - buttons.rejected.connect(self.close) - layout.addWidget(buttons, 3, 0, 1, 2) - - self.setLayout(layout) - - self.groups[s.SOUND].widgets[s.MUSIC_VOLUME].valueChanged.connect( - parent.frames.music.setVolume - ) - self.groups[s.SOUND].widgets[s.MUSIC_VOLUME].sliderPressed.connect( - parent.frames.music.play - ) - self.groups[s.SOUND].widgets[s.MUSIC_VOLUME].sliderReleased.connect( - parent.frames.music.pause - ) - - self.groups[s.SOUND].widgets[s.SFX_VOLUME].sliderReleased.connect( - parent.frames.stats.line_clear_sfx.play - ) - - self.show() - - def ok(self): - """ Save settings """ - - for group, elements in self.groups.items(): - for setting, widget in elements.widgets.items(): - if isinstance(widget, KeyButton): - value = widget.text() - elif isinstance(widget, QtWidgets.QCheckBox): - value = widget.isChecked() - elif isinstance(widget, QtWidgets.QSpinBox): - value = widget.value() - elif isinstance(widget, QtWidgets.QSlider): - value = widget.value() - settings[group][setting] = value - qsettings.setValue(group + "/" + setting, value) - self.close() - - -class Window(QtWidgets.QMainWindow): - """ Main window """ - - def __init__(self): - splash_screen = QtWidgets.QSplashScreen( - QtGui.QPixmap(consts.SPLASH_SCREEN_PATH) - ) - splash_screen.show() - - self.set_locale() - - self.load_settings() - - super().__init__() - self.setWindowTitle(__title__.upper()) - self.setAttribute(QtCore.Qt.WA_DeleteOnClose) - - self.setWindowIcon(QtGui.QIcon(consts.ICON_PATH)) - # Windows' taskbar icon - try: - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( - ".".join((__author__, __title__, __version__)) - ) - except AttributeError: - pass - - # Stylesheet - try: - import qdarkstyle - except ImportError: - pass - else: - self.setStyleSheet(qdarkstyle.load_stylesheet_from_environment()) - - for font_path in consts.STATS_FONT_PATH, consts.MATRIX_FONT_PATH: - QtGui.QFontDatabase.addApplicationFont(font_path) - - self.frames = Frames(self) - self.setCentralWidget(self.frames) - self.hold_queue = self.frames.hold_queue - self.matrix = self.frames.matrix - self.stats = self.frames.stats - - self.menu = self.menuBar() - - geometry = qsettings.value("WindowGeometry") - if geometry: - self.restoreGeometry(geometry) - else: - self.resize(*consts.DEFAULT_WINDOW_SIZE) - self.setWindowState( - QtCore.Qt.WindowStates( - int(qsettings.value("WindowState", QtCore.Qt.WindowActive)) - ) - ) - - splash_screen.finish(self) - - def set_locale(self): - app = QtWidgets.QApplication.instance() - - # Set appropriate thounsand separator characters - locale.setlocale(locale.LC_ALL, "") - # Qt - language = QtCore.QLocale.system().name()[:2] - - qt_translator = QtCore.QTranslator(app) - qt_translation_path = QtCore.QLibraryInfo.location( - QtCore.QLibraryInfo.TranslationsPath - ) - if qt_translator.load("qt_" + language, qt_translation_path): - app.installTranslator(qt_translator) - - tetris2000_translator = QtCore.QTranslator(app) - if tetris2000_translator.load(language, consts.LOCALE_PATH): - app.installTranslator(tetris2000_translator) - - def load_settings(self): - global s - s = SettingStrings() - global qsettings - qsettings = QtCore.QSettings(__author__, __title__) - global settings - settings = collections.OrderedDict( - [ - ( - s.KEYBOARD, - collections.OrderedDict( - [ - ( - s.MOVE_LEFT, - qsettings.value( - s.KEYBOARD + "/" + s.MOVE_LEFT, - consts.DEFAULT_MOVE_LEFT_KEY, - ), - ), - ( - s.MOVE_RIGHT, - qsettings.value( - s.KEYBOARD + "/" + s.MOVE_RIGHT, - consts.DEFAULT_MOVE_RIGHT_KEY, - ), - ), - ( - s.ROTATE_CLOCKWISE, - qsettings.value( - s.KEYBOARD + "/" + s.ROTATE_CLOCKWISE, - consts.DEFAULT_ROTATE_CLOCKWISE_KEY, - ), - ), - ( - s.ROTATE_COUNTERCLOCKWISE, - qsettings.value( - s.KEYBOARD + "/" + s.ROTATE_COUNTERCLOCKWISE, - consts.DEFAULT_ROTATE_COUNTERCLOCKWISE_KEY, - ), - ), - ( - s.SOFT_DROP, - qsettings.value( - s.KEYBOARD + "/" + s.SOFT_DROP, - consts.DEFAULT_SOFT_DROP_KEY, - ), - ), - ( - s.HARD_DROP, - qsettings.value( - s.KEYBOARD + "/" + s.HARD_DROP, - consts.DEFAULT_HARD_DROP_KEY, - ), - ), - ( - s.HOLD, - qsettings.value( - s.KEYBOARD + "/" + s.HOLD, consts.DEFAULT_HOLD_KEY - ), - ), - ( - s.PAUSE, - qsettings.value( - s.KEYBOARD + "/" + s.PAUSE, consts.DEFAULT_PAUSE_KEY - ), - ), - ] - ), - ), - ( - s.DELAYS, - collections.OrderedDict( - [ - ( - s.AUTO_SHIFT_DELAY, - int( - qsettings.value( - s.DELAYS + "/" + s.AUTO_SHIFT_DELAY, - consts.DEFAULT_AUTO_SHIFT_DELAY, - ) - ), - ), - ( - s.AUTO_REPEAT_RATE, - int( - qsettings.value( - s.DELAYS + "/" + s.AUTO_REPEAT_RATE, - consts.DEFAULT_AUTO_REPEAT_RATE, - ) - ), - ), - ] - ), - ), - ( - s.SOUND, - collections.OrderedDict( - [ - ( - s.MUSIC_VOLUME, - int( - qsettings.value( - s.SOUND + "/" + s.MUSIC_VOLUME, - consts.DEFAUT_MUSIC_VOLUME, - ) - ), - ), - ( - s.SFX_VOLUME, - int( - qsettings.value( - s.SOUND + "/" + s.SFX_VOLUME, - consts.DEFAULT_SFX_VOLUME, - ) - ), - ), - ] - ), - ), - ( - s.OTHER, - collections.OrderedDict( - [ - ( - s.GHOST, - bool( - qsettings.value( - s.OTHER + "/" + s.GHOST, - consts.DEFAULT_SHOW_GHOST, - ) - ), - ), - ( - s.SHOW_NEXT_QUEUE, - bool( - qsettings.value( - s.OTHER + "/" + s.SHOW_NEXT_QUEUE, - consts.DEFAULT_SHOW_NEXT_QUEUE, - ) - ), - ), - ( - s.HOLD_ENABLED, - bool( - qsettings.value( - s.OTHER + "/" + s.HOLD_ENABLED, - consts.DEFAULT_HOLD_ENABLED, - ) - ), - ), - ] - ), - ), - ] - ) - - def menuBar(self): - menu = super().menuBar() - - new_game_action = QtWidgets.QAction(self.tr("&New game"), self) - new_game_action.triggered.connect(self.frames.new_game) - menu.addAction(new_game_action) - - settings_action = QtWidgets.QAction(self.tr("&Settings"), self) - settings_action.triggered.connect(self.show_settings_dialog) - menu.addAction(settings_action) - - about_action = QtWidgets.QAction(self.tr("&About"), self) - about_action.triggered.connect(self.about) - menu.addAction(about_action) - return menu - - def show_settings_dialog(self): - SettingsDialog(self).exec_() - - self.frames.apply_settings() - - def about(self): - QtWidgets.QMessageBox.about( - self, - __title__, - self.tr( - """Tetris® clone by Adrien Malingrey - -Tetris Game Design by Alekseï Pajitnov -Graphism inspired by Tetris Effect -Window style sheet: qdarkstyle by Colin Duquesnoy -Fonts by Markus Koellmann, Peter Wiegel -Images from: -OpenGameArt.org by beren77, Duion -Pexels.com by Min An, Jaymantri, Felix Mittermeier -Pixabay.com by LoganArt -Pixnio.com by Adrian Pelletier -Unsplash.com by Aron, Patrick Fore, Ilnur Kalimullin, Gabriel Garcia Marengo, Adnanta Raharja -StockSnap.io by Nathan Anderson, José Ignacio Pompé -Musics from ocremix.org by: -CheDDer Nardz, djpretzel, MkVaff, Sir_NutS, R3FORGED, Sir_NutS -Sound effects made with voc-one by Simple-Media""" - ), - ) - if self.frames.playing: - self.frames.pause(False) - - def closeEvent(self, event): - if self.frames.playing: - answer = QtWidgets.QMessageBox.question( - self, - self.tr("Quit game?"), - self.tr("A game is in progress.\nDo you want to abord it?"), - QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, - QtWidgets.QMessageBox.Cancel, - ) - if answer == QtWidgets.QMessageBox.Cancel: - event.ignore() - self.frames.pause(False) - return - - self.frames.music.stop() - - # Save settings - qsettings.setValue(self.tr("High score"), self.stats.high_score) - qsettings.setValue("WindowGeometry", self.saveGeometry()) - qsettings.setValue("WindowState", int(self.windowState())) - - -if __name__ == "__main__": - app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) - win = Window() - win.show() - win.frames.new_game() - sys.exit(app.exec_()) +app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) +win = Window() +win.show() +win.frames.new_game() +sys.exit(app.exec_()) \ No newline at end of file diff --git a/dist/Tetris2000.exe b/dist/Tetris2000.exe deleted file mode 100644 index 7c51255..0000000 Binary files a/dist/Tetris2000.exe and /dev/null differ diff --git a/pyinstaller.spec b/pyinstaller.spec deleted file mode 100644 index 054719f..0000000 --- a/pyinstaller.spec +++ /dev/null @@ -1,43 +0,0 @@ -# -*- mode: python -*- - -block_cipher = None - - -a = Analysis(['Tetris2000.py'], - pathex=[], - binaries=[], - datas=[ - ("backgrounds/*", "backgrounds"), - ("fonts/*.ttf", "fonts"), - ("fonts/*.otf", "fonts"), - ("icons/*.ico", "icons"), - ("icons/splash_screen.png", "icons"), - ("locale/*.qm", "locale"), - ("musics/*.mp3", "musics"), - ("sfx/*.wav", "sfx") - ], - hiddenimports=[], - hookspath=[], - runtime_hooks=[], - excludes=["PyQt4", "PySide", "PySide2"], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher) -pyz = PYZ(a.pure, a.zipped_data, - cipher=block_cipher) -exe = EXE(pyz, - a.scripts, - exclude_binaries=True, - name='Tetris2000', - debug=False, - strip=False, - upx=False, - console=False, - icon='icons\icon.ico') -coll = COLLECT(exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=False, - name='Tetris2000') diff --git a/__version__.py b/source/__version__.py similarity index 94% rename from __version__.py rename to source/__version__.py index fbacdc3..ac06098 100644 --- a/__version__.py +++ b/source/__version__.py @@ -1,7 +1,7 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -__author__ = "Adrien Malingrey" -__title__ = "Tetris 2000" -__version__ = "0.3" +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +__author__ = "Adrien Malingrey" +__title__ = "Tetris 2000" +__version__ = "0.3" diff --git a/consts.py b/source/consts.py similarity index 95% rename from consts.py rename to source/consts.py index 3eccb7e..be4eaca 100644 --- a/consts.py +++ b/source/consts.py @@ -1,107 +1,108 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -import os -from qt5 import QtGui - - -# Paths -PATH = os.path.dirname(os.path.abspath(__file__)) -ICON_PATH = os.path.join(PATH, "icons", "icon.ico") -BG_IMAGE_DIR = os.path.join(PATH, "backgrounds") -START_BG_IMAGE_NAME = "01-spacefield_a-000.png" -MUSICS_DIR = os.path.join(PATH, "musics") -SFX_DIR = os.path.join(PATH, "sfx") -LINE_CLEAR_SFX_PATH = os.path.join(SFX_DIR, "line_clear.wav") -TETRIS_SFX_PATH = os.path.join(SFX_DIR, "tetris.wav") -ROTATE_SFX_PATH = os.path.join(SFX_DIR, "rotate.wav") -HARD_DROP_SFX_PATH = os.path.join(SFX_DIR, "hard_drop.wav") -WALL_SFX_PATH = os.path.join(SFX_DIR, "wall.wav") -LOCALE_PATH = os.path.join(PATH, "locale") -FONTS_DIR = os.path.join(PATH, "fonts") -STATS_FONT_PATH = os.path.join(FONTS_DIR, "PixelCaps!.otf") -STATS_FONT_NAME = "PixelCaps!" -MATRIX_FONT_PATH = os.path.join(FONTS_DIR, "maass slicer Italic.ttf") -MATRIX_FONT_NAME = "Maassslicer" - -SPLASH_SCREEN_PATH = os.path.join(PATH, "icons", "splash_screen.png") - -# Coordinates and direction -L, R, U, D = -1, 1, -1, 1 # Left, Right, Up, Down -CLOCKWISE, COUNTERCLOCKWISE = 1, -1 - -# Delays in milliseconds -ANIMATION_DELAY = 67 -INITIAL_SPEED = 1000 -ENTRY_DELAY = 80 -LINE_CLEAR_DELAY = 80 -LOCK_DELAY = 500 -TEMPORARY_TEXT_DURATION = 1000 -AFTER_LVL_15_ACCELERATION = 0.9 - -# Block Colors -BLOCK_BORDER_COLOR = QtGui.QColor(0, 159, 218, 255) -BLOCK_FILL_COLOR = QtGui.QColor(0, 159, 218, 25) -BLOCK_GLOWING_BORDER_COLOR = None -BLOCK_GLOWING_FILL_COLOR = QtGui.QColor(186, 211, 255, 70) -BLOCK_LIGHT_COLOR = QtGui.QColor(242, 255, 255, 40) -BLOCK_TRANSPARENT = QtGui.QColor(255, 255, 255, 0) -BLOCK_GLOWING = 0 -BLOCK_INITIAL_SIDE = 20 - -GHOST_BLOCK_BORDER_COLOR = QtGui.QColor(135, 213, 255, 255) -GHOST_BLOCK_FILL_COLOR = None -GHOST_BLOCK_GLOWING_FILL_COLOR = QtGui.QColor(201, 149, 205, 255) -GHOST_BLOCK_GLOWING = 1 - -# Grid -GRID_INVISIBLE_ROWS = 3 -GRID_DEFAULT_ROWS = 4 -GRID_DEFAULT_COLUMNS = 6 -GRID_GRIDLINE_COLOR = QtGui.QColor(255, 255, 255, 60) -GRID_HARD_DROP_MOVEMENT = 0.2 -GRID_SPOTLIGHT = 0, 0 - -# Matrix -MATRIX_ROWS = 20 -MATRIX_COLUMNS = 10 -MATRIX_TEXT_COLOR = QtGui.QColor(204, 255, 255, 128) - -# Next Queue -NEXT_QUEUE_ROWS = 16 -NEXT_QUEUE_COLUMNS = 6 - -# Stats frame -STATS_ROWS = 15 -STATS_COLUMNS = 6 -STATS_TEXT_COLOR = QtGui.QColor(0, 159, 218, 128) -SCORES = ( - {"name": "", "": 0, "Mini T-Spin": 1, "T-Spin": 4}, - {"name": "Single", "": 1, "Mini T-Spin": 2, "T-Spin": 8}, - {"name": "Double", "": 3, "T-Spin": 12}, - {"name": "Triple", "": 5, "T-Spin": 16}, - {"name": "Tetris", "": 8}, -) - -# Default settings -DEFAULT_WINDOW_SIZE = 839, 807 -# Key mapping -DEFAULT_MOVE_LEFT_KEY = "Left" -DEFAULT_MOVE_RIGHT_KEY = "Right" -DEFAULT_ROTATE_CLOCKWISE_KEY = "Up" -DEFAULT_ROTATE_COUNTERCLOCKWISE_KEY = "Control" -DEFAULT_SOFT_DROP_KEY = "Down" -DEFAULT_HARD_DROP_KEY = "Space" -DEFAULT_HOLD_KEY = "Shift" -DEFAULT_PAUSE_KEY = "Escape" -# Delays in milliseconds -DEFAULT_AUTO_SHIFT_DELAY = 170 -DEFAULT_AUTO_REPEAT_RATE = 20 -# Volume in percent -DEFAUT_MUSIC_VOLUME = 25 -DEFAULT_SFX_VOLUME = 50 -# Other -DEFAULT_SHOW_GHOST = True -DEFAULT_SHOW_NEXT_QUEUE = True -DEFAULT_HOLD_ENABLED = True +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import os +from .qt5 import QtGui + + +# Paths +PATH = os.path.dirname(os.path.abspath(__file__)) +PATH = os.path.dirname(PATH) +ICON_PATH = os.path.join(PATH, "icons", "icon.ico") +BG_IMAGE_DIR = os.path.join(PATH, "backgrounds") +START_BG_IMAGE_NAME = "01-spacefield_a-000.png" +MUSICS_DIR = os.path.join(PATH, "musics") +SFX_DIR = os.path.join(PATH, "sfx") +LINE_CLEAR_SFX_PATH = os.path.join(SFX_DIR, "line_clear.wav") +TETRIS_SFX_PATH = os.path.join(SFX_DIR, "tetris.wav") +ROTATE_SFX_PATH = os.path.join(SFX_DIR, "rotate.wav") +HARD_DROP_SFX_PATH = os.path.join(SFX_DIR, "hard_drop.wav") +WALL_SFX_PATH = os.path.join(SFX_DIR, "wall.wav") +LOCALE_PATH = os.path.join(PATH, "locale") +FONTS_DIR = os.path.join(PATH, "fonts") +STATS_FONT_PATH = os.path.join(FONTS_DIR, "PixelCaps!.otf") +STATS_FONT_NAME = "PixelCaps!" +MATRIX_FONT_PATH = os.path.join(FONTS_DIR, "maass slicer Italic.ttf") +MATRIX_FONT_NAME = "Maassslicer" + +SPLASH_SCREEN_PATH = os.path.join(PATH, "icons", "splash_screen.png") + +# Coordinates and direction +L, R, U, D = -1, 1, -1, 1 # Left, Right, Up, Down +CLOCKWISE, COUNTERCLOCKWISE = 1, -1 + +# Delays in milliseconds +ANIMATION_DELAY = 67 +INITIAL_SPEED = 1000 +ENTRY_DELAY = 80 +LINE_CLEAR_DELAY = 80 +LOCK_DELAY = 500 +TEMPORARY_TEXT_DURATION = 1000 +AFTER_LVL_15_ACCELERATION = 0.9 + +# Block Colors +BLOCK_BORDER_COLOR = QtGui.QColor(0, 159, 218, 255) +BLOCK_FILL_COLOR = QtGui.QColor(0, 159, 218, 25) +BLOCK_GLOWING_BORDER_COLOR = None +BLOCK_GLOWING_FILL_COLOR = QtGui.QColor(186, 211, 255, 70) +BLOCK_LIGHT_COLOR = QtGui.QColor(242, 255, 255, 40) +BLOCK_TRANSPARENT = QtGui.QColor(255, 255, 255, 0) +BLOCK_GLOWING = 0 +BLOCK_INITIAL_SIDE = 20 + +GHOST_BLOCK_BORDER_COLOR = QtGui.QColor(135, 213, 255, 255) +GHOST_BLOCK_FILL_COLOR = None +GHOST_BLOCK_GLOWING_FILL_COLOR = QtGui.QColor(201, 149, 205, 255) +GHOST_BLOCK_GLOWING = 1 + +# Grid +GRID_INVISIBLE_ROWS = 3 +GRID_DEFAULT_ROWS = 4 +GRID_DEFAULT_COLUMNS = 6 +GRID_GRIDLINE_COLOR = QtGui.QColor(255, 255, 255, 60) +GRID_HARD_DROP_MOVEMENT = 0.2 +GRID_SPOTLIGHT = 0, 0 + +# Matrix +MATRIX_ROWS = 20 +MATRIX_COLUMNS = 10 +MATRIX_TEXT_COLOR = QtGui.QColor(204, 255, 255, 128) + +# Next Queue +NEXT_QUEUE_ROWS = 16 +NEXT_QUEUE_COLUMNS = 6 + +# Stats frame +STATS_ROWS = 15 +STATS_COLUMNS = 6 +STATS_TEXT_COLOR = QtGui.QColor(0, 159, 218, 128) +SCORES = ( + {"name": "", "": 0, "Mini T-Spin": 1, "T-Spin": 4}, + {"name": "Single", "": 1, "Mini T-Spin": 2, "T-Spin": 8}, + {"name": "Double", "": 3, "T-Spin": 12}, + {"name": "Triple", "": 5, "T-Spin": 16}, + {"name": "Tetris", "": 8}, +) + +# Default settings +DEFAULT_WINDOW_SIZE = 839, 807 +# Key mapping +DEFAULT_MOVE_LEFT_KEY = "Left" +DEFAULT_MOVE_RIGHT_KEY = "Right" +DEFAULT_ROTATE_CLOCKWISE_KEY = "Up" +DEFAULT_ROTATE_COUNTERCLOCKWISE_KEY = "Control" +DEFAULT_SOFT_DROP_KEY = "Down" +DEFAULT_HARD_DROP_KEY = "Space" +DEFAULT_HOLD_KEY = "Shift" +DEFAULT_PAUSE_KEY = "Escape" +# Delays in milliseconds +DEFAULT_AUTO_SHIFT_DELAY = 170 +DEFAULT_AUTO_REPEAT_RATE = 20 +# Volume in percent +DEFAUT_MUSIC_VOLUME = 25 +DEFAULT_SFX_VOLUME = 50 +# Other +DEFAULT_SHOW_GHOST = True +DEFAULT_SHOW_NEXT_QUEUE = True +DEFAULT_HOLD_ENABLED = True diff --git a/source/game_gui.py b/source/game_gui.py new file mode 100644 index 0000000..5bb049f --- /dev/null +++ b/source/game_gui.py @@ -0,0 +1,1520 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import ctypes +import collections +import itertools +import locale +import os +import time + +from . import consts +from .consts import L, R, CLOCKWISE, COUNTERCLOCKWISE +from .qt5 import QtWidgets, QtCore, QtGui, QtMultimedia +from .__version__ import __title__, __author__, __version__ +from .point import Point +from .tetromino import Block, Tetromino, GhostPiece + + +class Grid(QtWidgets.QWidget): + """ + Mother class of Hold queue, Matrix, Next piece, and Next queue + """ + + ROWS = consts.GRID_DEFAULT_ROWS + consts.GRID_INVISIBLE_ROWS + COLUMNS = consts.GRID_DEFAULT_COLUMNS + STARTING_POSITION = Point( + consts.GRID_DEFAULT_COLUMNS // 2, + consts.GRID_DEFAULT_ROWS // 2 + consts.GRID_INVISIBLE_ROWS, + ) + GRIDLINE_COLOR = consts.GRID_GRIDLINE_COLOR + HARD_DROP_MOVEMENT = consts.GRID_HARD_DROP_MOVEMENT + SPOTLIGHT = Point(*consts.GRID_SPOTLIGHT) + + def __init__(self, frames): + super().__init__(frames) + self.setStyleSheet("background-color: transparent") + self.frames = frames + self.spotlight = self.SPOTLIGHT + self.piece = None + + def insert(self, piece, position=None): + """ + Add a Tetromino to self + Update its coordinates + """ + piece.insert_into(self, position or self.STARTING_POSITION) + self.piece = piece + self.update() + + def resizeEvent(self, event): + self.bottom = Block.side * self.ROWS + self.grid_top = consts.GRID_INVISIBLE_ROWS * Block.side + width = Block.side * self.COLUMNS + self.left = (self.width() - width) // 2 + self.right = width + self.left + self.top_left_corner = Point(self.left, 0) + + def paintEvent(self, event=None): + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + self.paint_grid(painter) + + if (not self.frames.paused or not self.frames.playing) and self.piece: + self.paint_piece(painter, self.piece) + + def paint_grid(self, painter): + painter.setPen(self.GRIDLINE_COLOR) + for x in (self.left + i * Block.side for i in range(self.COLUMNS + 1)): + painter.drawLine(x, self.grid_top, x, self.bottom) + for y in ( + j * Block.side for j in range(consts.GRID_INVISIBLE_ROWS, self.ROWS + 1) + ): + painter.drawLine(self.left, y, self.right, y) + + def paint_piece(self, painter, piece): + for mino in piece.minoes: + mino.paint(painter, self.top_left_corner, self.spotlight) + + +class Matrix(Grid): + """ + The rectangular arrangement of cells creating the active game area. + Tetriminos fall from the top-middle just above the Skyline (off-screen) to the bottom. + """ + + ROWS = consts.MATRIX_ROWS + consts.GRID_INVISIBLE_ROWS + COLUMNS = consts.MATRIX_COLUMNS + STARTING_POSITION = Point(COLUMNS // 2 - 1, consts.GRID_INVISIBLE_ROWS - 1) + TEXT_COLOR = consts.MATRIX_TEXT_COLOR + + drop_signal = QtCore.Signal(int) + lock_signal = QtCore.Signal(int, str) + + def __init__(self, frames): + super().__init__(frames) + + self.load_sfx() + + self.game_over = False + self.text = "" + self.temporary_texts = [] + + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + self.auto_repeat_delay = 0 + self.auto_repeat_timer = QtCore.QTimer() + self.auto_repeat_timer.setTimerType(QtCore.Qt.PreciseTimer) + self.auto_repeat_timer.timeout.connect(self.auto_repeat) + self.fall_timer = QtCore.QTimer() + self.fall_timer.timeout.connect(self.fall) + + self.cells = [] + + def load_sfx(self): + self.wall_sfx = QtMultimedia.QSoundEffect(self) + url = QtCore.QUrl.fromLocalFile(consts.WALL_SFX_PATH) + self.wall_sfx.setSource(url) + + self.rotate_sfx = QtMultimedia.QSoundEffect(self) + url = QtCore.QUrl.fromLocalFile(consts.ROTATE_SFX_PATH) + self.rotate_sfx.setSource(url) + + self.hard_drop_sfx = QtMultimedia.QSoundEffect(self) + url = QtCore.QUrl.fromLocalFile(consts.HARD_DROP_SFX_PATH) + self.hard_drop_sfx.setSource(url) + + def new_game(self): + self.game_over = False + self.lock_delay = consts.LOCK_DELAY + self.cells = [self.empty_row() for y in range(self.ROWS)] + self.setFocus() + self.actions_to_repeat = [] + self.wall_hit = False + + def new_level(self, level): + self.show_temporary_text(self.tr("Level\n") + str(level)) + self.speed = consts.INITIAL_SPEED * (0.8 - ((level - 1) * 0.007)) ** (level - 1) + self.fall_timer.start(self.speed) + if level > 15: + self.lock_delay = consts.LOCK_DELAY * (consts.AFTER_LVL_15_ACCELERATION ** (level-15)) + + def empty_row(self): + return [None for x in range(self.COLUMNS)] + + def is_empty_cell(self, coord): + x, y = coord.x, coord.y + return ( + 0 <= x < self.COLUMNS + and y < self.ROWS + and not (0 <= y and self.cells[y][x]) + ) + + def keyPressEvent(self, event): + if event.isAutoRepeat(): + return + + if not self.frames.playing: + return + + try: + action = self.keys[event.key()] + except KeyError: + return + + self.do(action) + if action in (s.MOVE_LEFT, s.MOVE_RIGHT, s.SOFT_DROP): + if action not in self.actions_to_repeat: + self.auto_repeat_wait() + self.actions_to_repeat.append(action) + + def keyReleaseEvent(self, event): + if event.isAutoRepeat(): + return + + if not self.frames.playing: + return + + try: + self.actions_to_repeat.remove(self.keys[event.key()]) + except (KeyError, ValueError): + pass + else: + self.auto_repeat_wait() + + if not self.actions_to_repeat: + for mino in self.piece.minoes: + mino.fade() + self.update() + + def auto_repeat_wait(self): + self.auto_repeat_delay = ( + time.time() + settings[s.DELAYS][s.AUTO_SHIFT_DELAY] / 1000 + ) + + def auto_repeat(self): + """ + Tapping the move button allows a single cell movement of the Tetrimino + in the direction pressed. + Holding down the move button triggers an Auto-Repeat movement + that allows the player to move a Tetrimino from one side of the Matrix + to the other in about 0.5 seconds. + This is essential on higher levels when the Fall Speed of a Tetrimino is very fast. + There is a slight delay between the time the move button is pressed + and the time when Auto-Repeat kicks in : s.AUTO_SHIFT_DELAY. + This delay prevents unwanted extra movement of a Tetrimino. + Auto-Repeat only affects Left/Right movement. + Auto-Repeat continues to the Next Tetrimino (after Lock Down) + as long as the move button remains pressed. + In addition, when Auto-Repeat begins, + and the player then holds the opposite direction button, + the Tetrimino then begins moving the opposite direction with the initial delay. + When any single button is then released, + the Tetrimino should again move in the direction still held, + with the Auto-Shift delay applied once more. + """ + if ( + not self.frames.playing + or self.frames.paused + or time.time() < self.auto_repeat_delay + ): + return + + if self.actions_to_repeat: + self.do(self.actions_to_repeat[-1]) + + def do(self, action): + """The player can move, rotate, Soft Drop, Hard Drop, + and Hold the falling Tetrimino (i.e., the Tetrimino in play). + """ + if action == s.PAUSE: + self.frames.pause(not self.frames.paused) + + if not self.frames.playing or self.frames.paused or not self.piece: + return + + for mino in self.piece.minoes: + mino.shine(0) + + if action == s.MOVE_LEFT: + if self.piece.move(L, 0): + self.lock_wait() + self.wall_hit = False + elif not self.wall_hit: + self.wall_hit = True + self.wall_sfx.play() + + elif action == s.MOVE_RIGHT: + if self.piece.move(R, 0): + self.rotate_sfx.play() + self.lock_wait() + self.wall_hit = False + elif not self.wall_hit: + self.wall_hit = True + self.wall_sfx.play() + + elif action == s.ROTATE_CLOCKWISE: + if self.piece.rotate(direction=CLOCKWISE): + self.rotate_sfx.play() + self.lock_wait() + self.wall_hit = False + elif not self.wall_hit: + self.wall_hit = True + self.wall_sfx.play() + + elif action == s.ROTATE_COUNTERCLOCKWISE: + if self.piece.rotate(direction=COUNTERCLOCKWISE): + self.lock_wait() + self.wall_hit = False + elif not self.wall_hit: + self.wall_hit = True + self.wall_sfx.play() + + elif action == s.SOFT_DROP: + if self.piece.soft_drop(): + self.drop_signal.emit(1) + self.wall_hit = False + elif not self.wall_hit: + self.wall_hit = True + self.wall_sfx.play() + + elif action == s.HARD_DROP: + trail = self.piece.hard_drop() + self.top_left_corner += Point(0, self.HARD_DROP_MOVEMENT * Block.side) + self.drop_signal.emit(2 * trail) + QtCore.QTimer.singleShot(consts.ANIMATION_DELAY, self.after_hard_drop) + self.hard_drop_sfx.play() + self.lock_phase() + + elif action == s.HOLD: + self.frames.hold() + + def after_hard_drop(self): + """ Reset the animation movement of the Matrix on a hard drop """ + self.top_left_corner -= Point(0, self.HARD_DROP_MOVEMENT * Block.side) + + def lock_wait(self): + self.fall_delay = time.time() + (self.speed + self.lock_delay) / 1000 + + def fall(self): + """ + Once a Tetrimino is generated, + it immediately drops one row (if no existing Block is in its path). + From here, it begins its descent to the bottom of the Matrix. + The Tetrimino will fall at its normal Fall Speed + whether or not it is being manipulated by the player. + """ + if self.piece: + if self.piece.move(0, 1): + self.lock_wait() + else: + if time.time() >= self.fall_delay: + self.lock_phase() + + def lock_phase(self): + """ + The player can perform the same actions on a Tetrimino in this phase + as he/she can in the Falling Phase, + as long as the Tetrimino is not yet Locked Down. + A Tetrimino that is Hard Dropped Locks Down immediately. + However, if a Tetrimino naturally falls or Soft Drops onto a landing Surface, + it is given a delay (self.fall_delay) on a Lock Down Timer + before it actually Locks Down. + """ + + self.wall_sfx.play() + + # Enter minoes into the matrix + for mino in self.piece.minoes: + if mino.coord.y >= 0: + self.cells[mino.coord.y][mino.coord.x] = mino + mino.shine(glowing=2, delay=consts.ANIMATION_DELAY) + QtCore.QTimer.singleShot(consts.ANIMATION_DELAY, self.update) + self.update() + + if all( + mino.coord.y < consts.GRID_INVISIBLE_ROWS for mino in self.piece.minoes + ): + self.frames.game_over() + return + + """ + In this phase, + the engine looks for patterns made from Locked Down Blocks in the Matrix. + Once a pattern has been matched, + it can trigger any number of Tetris variant-related effects. + The classic pattern is the Line Clear pattern. + This pattern is matched when one or more rows of 10 horizontally aligned + Matrix cells are occupied by Blocks. + The matching Blocks are then marked for removal on a hit list. + Blocks on the hit list are cleared from the Matrix at a later time + in the Eliminate Phase. + """ + # Dectect complete lines + self.complete_lines = [] + for y, row in enumerate(self.cells): + if all(cell for cell in row): + self.complete_lines.append(y) + for block in row: + block.shine() + self.spotlight = row[self.COLUMNS // 2].coord + self.auto_repeat_timer.stop() + self.lock_signal.emit(len(self.complete_lines), self.piece.t_spin) + + if self.complete_lines: + self.fall_timer.stop() + QtCore.QTimer.singleShot(consts.LINE_CLEAR_DELAY, self.eliminate_phase) + else: + self.frames.new_piece() + + def eliminate_phase(self): + """ + Any Minos marked for removal, i.e., on the hit list, + are cleared from the Matrix in this phase. + If this results in one or more complete 10-cell rows in the Matrix + becoming unoccupied by Minos, + then all Minos above that row(s) collapse, + or fall by the number of complete rows cleared from the Matrix. + """ + for y in self.complete_lines: + del self.cells[y] + self.cells.insert(0, self.empty_row()) + + for y, row in enumerate(self.cells): + for x, block in enumerate(row): + if block: + block.coord.setX(x) + block.coord.setY(y) + + self.update() + self.auto_repeat_wait() + self.auto_repeat_timer.start(settings[s.DELAYS][s.AUTO_REPEAT_RATE]) + + self.frames.new_piece() + + def paintEvent(self, event): + """ + Draws grid, actual piece, blocks in the Matrix and show texts + """ + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + self.paint_grid(painter) + + if not self.frames.paused or self.game_over: + if self.piece: + if settings[s.OTHER][s.GHOST]: + self.ghost = GhostPiece(self.piece) + self.spotlight = self.ghost.minoes[0].coord + self.paint_piece(painter, self.ghost) + self.paint_piece(painter, self.piece) + + # Blocks in matrix + for row in self.cells: + for block in row: + if block: + block.paint(painter, self.top_left_corner, self.spotlight) + + if self.frames.playing and self.frames.paused: + painter.setFont(QtGui.QFont("Maassslicer", 0.75 * Block.side)) + painter.setPen(self.TEXT_COLOR) + painter.drawText( + self.rect(), + QtCore.Qt.AlignCenter | QtCore.Qt.TextWordWrap, + self.tr("PAUSE\n\nPress %s\nto resume") % settings[s.KEYBOARD][s.PAUSE], + ) + if self.game_over: + painter.setFont(QtGui.QFont("Maassslicer", Block.side)) + painter.setPen(self.TEXT_COLOR) + painter.drawText( + self.rect(), + QtCore.Qt.AlignCenter | QtCore.Qt.TextWordWrap, + self.tr("GAME\nOVER"), + ) + if self.temporary_texts: + painter.setFont(self.temporary_text_font) + painter.setPen(self.TEXT_COLOR) + painter.drawText( + self.rect(), + QtCore.Qt.AlignHCenter | QtCore.Qt.TextWordWrap, + "\n\n\n" + "\n\n".join(self.temporary_texts), + ) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.temporary_text_font = QtGui.QFont(consts.MATRIX_FONT_NAME, Block.side) + + def show_temporary_text(self, text): + self.temporary_texts.append(text.upper()) + self.font = self.temporary_text_font + self.update() + QtCore.QTimer.singleShot(consts.TEMPORARY_TEXT_DURATION, self.delete_text) + + def delete_text(self): + del self.temporary_texts[0] + self.update() + + def focusOutEvent(self, event): + if self.frames.playing: + self.frames.pause(True) + + +class HoldQueue(Grid): + """ + The Hold Queue allows the player to “hold” a falling Tetrimino for as long as they wish. + Holding a Tetrimino releases the Tetrimino already in the Hold Queue (if one exists). + """ + + def paintEvent(self, event): + if not settings[s.OTHER][s.HOLD_ENABLED]: + return + + super().paintEvent(event) + + +class NextQueue(Grid): + """ + The Next Queue allows the player to see the Next Tetrimino that will be generated + and put into play. + """ + + ROWS = consts.NEXT_QUEUE_ROWS + COLUMNS = consts.NEXT_QUEUE_COLUMNS + + def __init__(self, parent): + super().__init__(parent) + self.pieces = [] + + def new_piece(self): + self.pieces = self.pieces[1:] + [Tetromino()] + self.insert_pieces() + + def insert_pieces(self): + for y, piece in enumerate(self.pieces): + piece.insert_into(self, Point(3, 3 * y + 1)) + + def paintEvent(self, event=None): + if not settings[s.OTHER][s.SHOW_NEXT_QUEUE]: + return + + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + if not self.frames.paused: + for piece in self.pieces: + self.paint_piece(painter, piece) + + +class AspectRatioWidget(QtWidgets.QWidget): + """ + Keeps aspect ratio of child widget on resize + https://stackoverflow.com/questions/48043469/how-to-lock-aspect-ratio-while-resizing-the-window + """ + + def __init__(self, widget, parent): + super().__init__(parent) + self.aspect_ratio = widget.size().width() / widget.size().height() + self.setLayout(QtWidgets.QBoxLayout(QtWidgets.QBoxLayout.LeftToRight, self)) + # add spacer, then widget, then spacer + self.layout().addItem(QtWidgets.QSpacerItem(0, 0)) + self.layout().addWidget(widget) + self.layout().addItem(QtWidgets.QSpacerItem(0, 0)) + + def resizeEvent(self, e): + w = e.size().width() + h = e.size().height() + + if w / h > self.aspect_ratio: # too wide + self.layout().setDirection(QtWidgets.QBoxLayout.LeftToRight) + widget_stretch = h * self.aspect_ratio + outer_stretch = (w - widget_stretch) / 2 + 0.5 + else: # too tall + self.layout().setDirection(QtWidgets.QBoxLayout.TopToBottom) + widget_stretch = w / self.aspect_ratio + outer_stretch = (h - widget_stretch) / 2 + 0.5 + + self.layout().setStretch(0, outer_stretch) + self.layout().setStretch(1, widget_stretch) + self.layout().setStretch(2, outer_stretch) + + +class Stats(QtWidgets.QWidget): + """ + Show informations relevant to the game being played is displayed on-screen. + Looks for patterns made from Locked Down Blocks in the Matrix and calculate score. + """ + + ROWS = consts.STATS_ROWS + COLUMNS = consts.STATS_COLUMNS + TEXT_COLOR = consts.STATS_TEXT_COLOR + + temporary_text = QtCore.Signal(str) + + def __init__(self, frames): + super().__init__(frames) + self.frames = frames + self.setStyleSheet("background-color: transparent") + + self.load_sfx() + + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + self.text_options = QtGui.QTextOption(QtCore.Qt.AlignRight) + self.text_options.setWrapMode(QtGui.QTextOption.WrapAtWordBoundaryOrAnywhere) + + self.clock = QtCore.QTimer() + self.clock.timeout.connect(self.tick) + + self.high_score = int(qsettings.value(self.tr("High score"), 0)) + + def load_sfx(self): + self.line_clear_sfx = QtMultimedia.QSoundEffect(self) + url = QtCore.QUrl.fromLocalFile(consts.LINE_CLEAR_SFX_PATH) + self.line_clear_sfx.setSource(url) + + self.tetris_sfx = QtMultimedia.QSoundEffect(self) + url = QtCore.QUrl.fromLocalFile(consts.TETRIS_SFX_PATH) + self.tetris_sfx.setSource(url) + + def new_game(self): + self.level -= 1 + self.goal = 0 + self.complete_lines_total = 0 + self.score_total = 1 + self.t_spin_total = 0 + self.mini_t_spin_total = 0 + self.nb_back_to_back = 0 + self.back_to_back_scores = None + self.combo = -1 + self.combos_total = 0 + self.max_combo = 0 + self.chronometer = 0 + self.nb_tetro = 0 + self.clock.start(1000) + self.lines_stats = [0, 0, 0, 0, 0] + + def new_level(self): + self.level += 1 + self.goal += 5 * self.level + return self.level + + def update_score(self, nb_complete_lines, t_spin): + """ + The player scores points by performing Single, Double, Triple, + and Tetris Line Clears, as well as T-Spins and Mini T-Spins. + Soft and Hard Drops also award points. + There is a special bonus for Back-to-Backs, + which is when two actions such as a Tetris and T-Spin Double take place + without a Single, Double, or Triple Line Clear occurring between them. + Scoring for Line Clears, T-Spins, and Mini T-Spins are level dependent, + while Hard and Soft Drop point values remain constant. + """ + self.nb_tetro += 1 + if nb_complete_lines: + self.complete_lines_total += nb_complete_lines + self.lines_stats[nb_complete_lines] += 1 + if t_spin == "T-Spin": + self.t_spin_total += 1 + elif t_spin == "Mini T-Spin": + self.mini_t_spin_total += 1 + + score = consts.SCORES[nb_complete_lines][t_spin] + + if score: + text = " ".join((t_spin, consts.SCORES[nb_complete_lines]["name"])) + if (t_spin and nb_complete_lines) or nb_complete_lines == 4: + self.tetris_sfx.play() + elif t_spin or nb_complete_lines: + self.line_clear_sfx.play() + + self.goal -= score + score = 100 * self.level * score + self.score_total += score + + self.temporary_text.emit(text + "\n{:n}".format(score)) + + # ============================================================================== + # Combo + # Bonus for complete lines on each consecutive lock downs + # if nb_complete_lines: + # ============================================================================== + if nb_complete_lines: + self.combo += 1 + if self.combo > 0: + if nb_complete_lines == 1: + combo_score = 20 * self.combo * self.level + else: + combo_score = 50 * self.combo * self.level + self.score_total += combo_score + self.max_combo = max(self.max_combo, self.combo) + self.combos_total += 1 + self.temporary_text.emit( + self.tr("COMBO x{:n}\n{:n}").format(self.combo, combo_score) + ) + else: + self.combo = -1 + + # ============================================================================== + # Back-to_back sequence + # Two major bonus actions, such as two Tetrises, performed without + # a Single, Double, or Triple Line Clear occurring between them. + # Bonus for Tetrises, T-Spin Line Clears, and Mini T-Spin Line Clears + # performed consecutively in a B2B sequence. + # ============================================================================== + if (t_spin and nb_complete_lines) or nb_complete_lines == 4: + if self.back_to_back_scores is not None: + self.back_to_back_scores.append(score // 2) + else: + # The first Line Clear in the Back-to-Back sequence + # does not receive the Back-to-Back Bonus. + self.back_to_back_scores = [] + elif nb_complete_lines and not t_spin: + # A Back-to-Back sequence is only broken by a Single, Double, or Triple Line Clear. + # Locking down a Tetrimino without clearing a line + # or holding a Tetrimino does not break the Back-to-Back sequence. + # T-Spins and Mini T-Spins that do not clear any lines + # do not receive the Back-to-Back Bonus; instead they are scored as normal. + # They also cannot start a Back-to-Back sequence, however, + # they do not break an existing Back-to-Back sequence. + if self.back_to_back_scores: + b2b_score = sum(self.back_to_back_scores) + self.score_total += b2b_score + self.nb_back_to_back += 1 + self.temporary_text.emit( + self.tr("BACK TO BACK\n{:n}").format(b2b_score) + ) + self.back_to_back_scores = None + + self.high_score = max(self.score_total, self.high_score) + self.update() + + def update_drop_score(self, n): + """ Tetrimino is Soft Dropped for n lines or Hard Dropped for (n/2) lines""" + self.score_total += n + self.high_score = max(self.score_total, self.high_score) + self.update() + + def tick(self): + self.chronometer += 1 + self.update() + + def paintEvent(self, event): + if not self.frames.playing and not self.frames.matrix.game_over: + return + + painter = QtGui.QPainter(self) + painter.setFont(self.font) + painter.setPen(self.TEXT_COLOR) + + painter.drawText( + QtCore.QRectF(self.rect()), self.text(sep="\n\n"), self.text_options + ) + + def text(self, full_stats=False, sep="\n"): + text = ( + self.tr("Score: ") + + locale.format("%i", self.score_total, grouping=True, monetary=True) + + sep + + self.tr("High score: ") + + locale.format("%i", self.high_score, grouping=True, monetary=True) + + sep + + self.tr("Time: {}\n").format( + time.strftime("%H:%M:%S", time.gmtime(self.chronometer)) + ) + + sep + + self.tr("Level: ") + + locale.format("%i", self.level, grouping=True, monetary=True) + + sep + + self.tr("Goal: ") + + locale.format("%i", self.goal, grouping=True, monetary=True) + + sep + + self.tr("Lines: ") + + locale.format( + "%i", self.complete_lines_total, grouping=True, monetary=True + ) + + sep + + self.tr("Mini T-Spins: ") + + locale.format("%i", self.mini_t_spin_total, grouping=True, monetary=True) + + sep + + self.tr("T-Spins: ") + + locale.format("%i", self.t_spin_total, grouping=True, monetary=True) + + sep + + self.tr("Back-to-back: ") + + locale.format("%i", self.nb_back_to_back, grouping=True, monetary=True) + + sep + + self.tr("Max combo: ") + + locale.format("%i", self.max_combo, grouping=True, monetary=True) + + sep + + self.tr("Combos: ") + + locale.format("%i", self.combos_total, grouping=True, monetary=True) + ) + if full_stats: + minutes = self.chronometer / 60 + text += ( + "\n" + + sep + + self.tr("Lines per minute: {:.1f}").format( + self.complete_lines_total / minutes + ) + + sep + + self.tr("Tetrominos locked down: ") + + locale.format("%i", self.nb_tetro, grouping=True, monetary=True) + + sep + + self.tr("Tetrominos per minute: {:.1f}").format( + self.nb_tetro / minutes + ) + + sep + ) + text += sep.join( + score_type["name"] + + self.tr(": ") + + locale.format("%i", nb, grouping=True, monetary=True) + for score_type, nb in tuple(zip(consts.SCORES, self.lines_stats))[1:] + ) + return text + + def resizeEvent(self, event): + self.font = QtGui.QFont(consts.STATS_FONT_NAME, Block.side / 3.5) + + +class Frames(QtWidgets.QWidget): + """ + Display Hold queue, Matrix, Next piece, Next queue and Stats. + Manage interactions between them. + """ + + def __init__(self, parent): + super().__init__(parent) + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + + self.playing = False + self.paused = False + self.load_music() + + self.hold_queue = HoldQueue(self) + self.matrix = Matrix(self) + self.next_piece = Grid(self) + self.stats = Stats(self) + self.next_queue = NextQueue(self) + + self.matrices = (self.hold_queue, self.matrix, self.next_piece) + self.columns = sum(matrix.COLUMNS + 2 for matrix in self.matrices) + self.rows = self.matrix.ROWS + consts.GRID_INVISIBLE_ROWS + + w = QtWidgets.QWidget(self) + w.setStyleSheet("background-color: transparent") + grid = QtWidgets.QGridLayout() + for x in range(self.rows): + grid.setRowStretch(x, 1) + for y in range(self.columns): + grid.setColumnStretch(y, 1) + grid.setSpacing(0) + x, y = 0, 0 + grid.addWidget( + self.hold_queue, y, x, self.hold_queue.ROWS + 1, self.hold_queue.COLUMNS + 2 + ) + x += self.hold_queue.COLUMNS + 2 + grid.addWidget( + self.matrix, + y, + x, + self.matrix.ROWS + consts.GRID_INVISIBLE_ROWS, + self.matrix.COLUMNS + 2, + ) + x += self.matrix.COLUMNS + 3 + grid.addWidget( + self.next_piece, y, x, self.next_piece.ROWS + 1, self.next_piece.COLUMNS + 2 + ) + x, y = 0, self.hold_queue.ROWS + 1 + grid.addWidget(self.stats, y + 1, x, self.stats.ROWS, self.stats.COLUMNS + 1) + x += self.stats.COLUMNS + self.matrix.COLUMNS + 5 + grid.addWidget( + self.next_queue, y, x, self.next_queue.ROWS, self.next_queue.COLUMNS + 2 + ) + w.setLayout(grid) + w.resize(self.columns, self.rows) + asw = AspectRatioWidget(w, self) + layout = QtWidgets.QGridLayout() + layout.addWidget(asw) + self.setLayout(layout) + + self.stats.temporary_text.connect(self.matrix.show_temporary_text) + self.matrix.drop_signal.connect(self.stats.update_drop_score) + self.matrix.lock_signal.connect(self.stats.update_score) + + self.set_background( + os.path.join(consts.BG_IMAGE_DIR, consts.START_BG_IMAGE_NAME) + ) + + self.apply_settings() + + def load_music(self): + playlist = QtMultimedia.QMediaPlaylist(self) + for entry in os.scandir(consts.MUSICS_DIR): + path = os.path.join(consts.MUSICS_DIR, entry.name) + url = QtCore.QUrl.fromLocalFile(path) + music = QtMultimedia.QMediaContent(url) + playlist.addMedia(music) + playlist.setPlaybackMode(QtMultimedia.QMediaPlaylist.Loop) + self.music = QtMultimedia.QMediaPlayer(self) + self.music.setAudioRole(QtMultimedia.QAudio.GameRole) + self.music.setPlaylist(playlist) + self.music.setVolume(settings[s.SOUND][s.MUSIC_VOLUME]) + + def apply_settings(self): + if self.music.volume() > 5 and self.playing: + self.music.play() + else: + self.music.pause() + + if self.playing: + self.hold_enabled = settings[s.OTHER][s.HOLD_ENABLED] + self.pause(False) + + self.matrix.keys = { + getattr(QtCore.Qt, "Key_" + name): action + for action, name in settings[s.KEYBOARD].items() + } + self.matrix.auto_repeat_timer.start(settings[s.DELAYS][s.AUTO_REPEAT_RATE]) + self.matrix.spotlight = Matrix.SPOTLIGHT + + for sfx in ( + self.matrix.rotate_sfx, + self.matrix.wall_sfx, + self.stats.line_clear_sfx, + self.stats.tetris_sfx, + ): + sfx.setVolume(settings[s.SOUND][s.SFX_VOLUME]) + + def resizeEvent(self, event): + Block.side = 0.9 * min(self.width() // self.columns, self.height() // self.rows) + self.resize_bg_image() + + def reset_backgrounds(self): + backgrounds_paths = ( + os.path.join(consts.BG_IMAGE_DIR, entry.name) + for entry in os.scandir(consts.BG_IMAGE_DIR) + ) + self.backgrounds_cycle = itertools.cycle(backgrounds_paths) + + def set_background(self, path): + self.bg_image = QtGui.QImage(path) + self.resize_bg_image() + + def resize_bg_image(self): + self.resized_bg_image = QtGui.QPixmap.fromImage(self.bg_image) + self.resized_bg_image = self.resized_bg_image.scaled( + self.size(), + QtCore.Qt.KeepAspectRatioByExpanding, + QtCore.Qt.SmoothTransformation, + ) + self.resized_bg_image = self.resized_bg_image.copy( + (self.resized_bg_image.width() - self.width()) // 2, + (self.resized_bg_image.height() - self.height()) // 2, + self.width(), + self.height(), + ) + self.update() + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + painter.drawPixmap(self.rect(), self.resized_bg_image) + + def new_game(self): + if self.playing: + answer = QtWidgets.QMessageBox.question( + self, + self.tr("New game"), + self.tr("A game is in progress.\n" "Do you want to abord it?"), + QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, + QtWidgets.QMessageBox.Cancel, + ) + if answer == QtWidgets.QMessageBox.Cancel: + self.pause(False) + return + self.music.stop() + + self.reset_backgrounds() + self.stats.level, ok = QtWidgets.QInputDialog.getInt( + self, + self.tr("New game"), + self.tr("Start level:"), + 1, + 1, + 15, + flags=QtCore.Qt.WindowSystemMenuHint | QtCore.Qt.WindowTitleHint, + ) + if not ok: + return + + self.playing = True + self.load_music() + self.music.play() + self.hold_queue.piece = None + self.stats.new_game() + self.matrix.new_game() + self.next_queue.pieces = [Tetromino() for _ in range(5)] + self.next_queue.insert_pieces() + self.next_piece.insert(Tetromino()) + self.pause(False) + self.new_level() + self.new_piece() + + def new_level(self): + self.set_background(next(self.backgrounds_cycle)) + level = self.stats.new_level() + self.matrix.new_level(level) + + def new_piece(self): + if self.stats.goal <= 0: + self.new_level() + self.matrix.insert(self.next_piece.piece) + self.matrix.lock_wait() + self.next_piece.insert(self.next_queue.pieces[0]) + self.next_queue.new_piece() + self.hold_enabled = settings[s.OTHER][s.HOLD_ENABLED] + self.update() + + if not self.matrix.piece.move(0, 0): + self.game_over() + return + + self.matrix.fall_timer.start(self.matrix.speed) + + def pause(self, paused): + if not self.playing: + return + + if paused: + self.paused = True + self.update() + self.matrix.fall_timer.stop() + self.stats.clock.stop() + self.matrix.auto_repeat_timer.stop() + self.music.pause() + else: + self.matrix.text = "" + self.update() + QtCore.QTimer.singleShot(1000, self.resume) + + def resume(self): + self.paused = False + self.update() + self.matrix.fall_timer.start(self.matrix.speed) + self.stats.clock.start(1000) + self.matrix.auto_repeat_timer.start(settings[s.DELAYS][s.AUTO_REPEAT_RATE]) + self.music.play() + + def hold(self): + """ + Using the Hold command places the Tetrimino in play into the Hold Queue. + The previously held Tetrimino (if one exists) will then start falling + from the top of the Matrix, + beginning from its generation position and North Facing orientation. + Only one Tetrimino may be held at a time. + A Lock Down must take place between Holds. + For example, at the beginning, the first Tetrimino is generated and begins to fall. + The player decides to hold this Tetrimino. + Immediately the Next Tetrimino is generated from the Next Queue and begins to fall. + The player must first Lock Down this Tetrimino before holding another Tetrimino. + In other words, you may not Hold the same Tetrimino more than once. + """ + + if not self.hold_enabled: + return + + piece = self.hold_queue.piece + if piece: + self.hold_queue.insert(self.matrix.piece) + self.matrix.insert(piece) + self.update() + else: + self.hold_queue.insert(self.matrix.piece) + self.new_piece() + self.hold_enabled = False + + def game_over(self): + self.matrix.fall_timer.stop() + self.stats.clock.stop() + self.matrix.auto_repeat_timer.stop() + self.music.stop() + self.playing = False + self.matrix.game_over = True + msgbox = QtWidgets.QMessageBox(self) + msgbox.setWindowTitle(self.tr("Game over")) + msgbox.setIcon(QtWidgets.QMessageBox.Information) + if self.stats.score_total == self.stats.high_score: + msgbox.setText( + self.tr("Congratulations!\nYou have the high score: {}").format( + locale.format( + "%i", self.stats.high_score, grouping=True, monetary=True + ) + ) + ) + qsettings.setValue(self.tr("High score"), self.stats.high_score) + else: + msgbox.setText( + self.tr("Score: {}\nHigh score: {}").format( + locale.format( + "%i", self.stats.score_total, grouping=True, monetary=True + ), + locale.format( + "%i", self.stats.high_score, grouping=True, monetary=True + ), + ) + ) + msgbox.setDetailedText(self.stats.text(full_stats=True)) + # Find and set default the "show details" button + for button in msgbox.buttons(): + if msgbox.buttonRole(button) == QtWidgets.QMessageBox.ActionRole: + msgbox.setDefaultButton(button) + msgbox.exec_() + + +class SettingStrings(QtCore.QObject): + """ + Setting string for translation + """ + + def __init__(self): + super().__init__() + + self.KEYBOARD = self.tr("Keyboard settings") + self.MOVE_LEFT = self.tr("Move left") + self.MOVE_RIGHT = self.tr("Move right") + self.ROTATE_CLOCKWISE = self.tr("Rotate clockwise") + self.ROTATE_COUNTERCLOCKWISE = self.tr("Rotate counterclockwise") + self.SOFT_DROP = self.tr("Soft drop") + self.HARD_DROP = self.tr("Hard drop") + self.HOLD = self.tr("Hold") + self.PAUSE = self.tr("Pause") + self.OTHER = self.tr("Other settings") + + self.DELAYS = self.tr("Delays") + self.AUTO_SHIFT_DELAY = self.tr("Auto-shift delay") + self.AUTO_REPEAT_RATE = self.tr("Auto-repeat rate") + + self.SOUND = self.tr("Sound") + self.MUSIC_VOLUME = self.tr("Music volume") + self.SFX_VOLUME = self.tr("Effects volume") + + self.GHOST = self.tr("Show ghost piece") + self.SHOW_NEXT_QUEUE = self.tr("Show next queue") + self.HOLD_ENABLED = self.tr("Hold enabled") + + +class KeyButton(QtWidgets.QPushButton): + """ Button widget capturing key name on focus """ + + names = { + value: name.replace("Key_", "") + for name, value in QtCore.Qt.__dict__.items() + if "Key_" in name + } + + def __init__(self, *args): + super().__init__(*args) + + def keyPressEvent(self, event): + key = event.key() + self.setText(self.names[key]) + + +class SettingsGroup(QtWidgets.QGroupBox): + """ Group box of a type of settings """ + + def __init__(self, group, parent, cls): + super().__init__(group, parent) + layout = QtWidgets.QFormLayout(self) + self.widgets = {} + for setting, value in settings[group].items(): + if cls == KeyButton: + widget = KeyButton(value) + elif cls == QtWidgets.QCheckBox: + widget = QtWidgets.QCheckBox(setting) + widget.setChecked(value) + elif cls == QtWidgets.QSpinBox: + widget = QtWidgets.QSpinBox() + widget.setRange(0, 1000) + widget.setValue(value) + widget.setSuffix(" ms") + elif cls == QtWidgets.QSlider: + widget = QtWidgets.QSlider(QtCore.Qt.Horizontal) + widget.setValue(value) + if cls == QtWidgets.QCheckBox: + layout.addRow(widget) + else: + layout.addRow(setting, widget) + self.widgets[setting] = widget + self.setLayout(layout) + + +class SettingsDialog(QtWidgets.QDialog): + """ Show settings dialog """ + + def __init__(self, parent): + super().__init__(parent) + self.setWindowTitle(self.tr("Settings")) + self.setModal(True) + + layout = QtWidgets.QGridLayout() + + self.groups = {} + self.groups[s.KEYBOARD] = SettingsGroup(s.KEYBOARD, self, KeyButton) + self.groups[s.DELAYS] = SettingsGroup(s.DELAYS, self, QtWidgets.QSpinBox) + self.groups[s.SOUND] = SettingsGroup(s.SOUND, self, QtWidgets.QSlider) + self.groups[s.OTHER] = SettingsGroup(s.OTHER, self, QtWidgets.QCheckBox) + + layout.addWidget(self.groups[s.KEYBOARD], 0, 0, 3, 1) + layout.addWidget(self.groups[s.DELAYS], 0, 1) + layout.addWidget(self.groups[s.SOUND], 1, 1) + layout.addWidget(self.groups[s.OTHER], 2, 1) + + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self.ok) + buttons.rejected.connect(self.close) + layout.addWidget(buttons, 3, 0, 1, 2) + + self.setLayout(layout) + + self.groups[s.SOUND].widgets[s.MUSIC_VOLUME].valueChanged.connect( + parent.frames.music.setVolume + ) + self.groups[s.SOUND].widgets[s.MUSIC_VOLUME].sliderPressed.connect( + parent.frames.music.play + ) + self.groups[s.SOUND].widgets[s.MUSIC_VOLUME].sliderReleased.connect( + parent.frames.music.pause + ) + + self.groups[s.SOUND].widgets[s.SFX_VOLUME].sliderReleased.connect( + parent.frames.stats.line_clear_sfx.play + ) + + self.show() + + def ok(self): + """ Save settings """ + + for group, elements in self.groups.items(): + for setting, widget in elements.widgets.items(): + if isinstance(widget, KeyButton): + value = widget.text() + elif isinstance(widget, QtWidgets.QCheckBox): + value = widget.isChecked() + elif isinstance(widget, QtWidgets.QSpinBox): + value = widget.value() + elif isinstance(widget, QtWidgets.QSlider): + value = widget.value() + settings[group][setting] = value + qsettings.setValue(group + "/" + setting, value) + self.close() + + +class Window(QtWidgets.QMainWindow): + """ Main window """ + + def __init__(self): + splash_screen = QtWidgets.QSplashScreen( + QtGui.QPixmap(consts.SPLASH_SCREEN_PATH) + ) + splash_screen.show() + + self.set_locale() + + self.load_settings() + + super().__init__() + self.setWindowTitle(__title__.upper()) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + + self.setWindowIcon(QtGui.QIcon(consts.ICON_PATH)) + # Windows' taskbar icon + try: + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + ".".join((__author__, __title__, __version__)) + ) + except AttributeError: + pass + + # Stylesheet + try: + import qdarkstyle + except ImportError: + pass + else: + self.setStyleSheet(qdarkstyle.load_stylesheet_from_environment()) + + for font_path in consts.STATS_FONT_PATH, consts.MATRIX_FONT_PATH: + QtGui.QFontDatabase.addApplicationFont(font_path) + + self.frames = Frames(self) + self.setCentralWidget(self.frames) + self.hold_queue = self.frames.hold_queue + self.matrix = self.frames.matrix + self.stats = self.frames.stats + + self.menu = self.menuBar() + + geometry = qsettings.value("WindowGeometry") + if geometry: + self.restoreGeometry(geometry) + else: + self.resize(*consts.DEFAULT_WINDOW_SIZE) + self.setWindowState( + QtCore.Qt.WindowStates( + int(qsettings.value("WindowState", QtCore.Qt.WindowActive)) + ) + ) + + splash_screen.finish(self) + + def set_locale(self): + app = QtWidgets.QApplication.instance() + + # Set appropriate thounsand separator characters + locale.setlocale(locale.LC_ALL, "") + # Qt + language = QtCore.QLocale.system().name()[:2] + + qt_translator = QtCore.QTranslator(app) + qt_translation_path = QtCore.QLibraryInfo.location( + QtCore.QLibraryInfo.TranslationsPath + ) + if qt_translator.load("qt_" + language, qt_translation_path): + app.installTranslator(qt_translator) + + tetris2000_translator = QtCore.QTranslator(app) + if tetris2000_translator.load(language, consts.LOCALE_PATH): + app.installTranslator(tetris2000_translator) + + def load_settings(self): + global s + s = SettingStrings() + global qsettings + qsettings = QtCore.QSettings(__author__, __title__) + global settings + settings = collections.OrderedDict( + [ + ( + s.KEYBOARD, + collections.OrderedDict( + [ + ( + s.MOVE_LEFT, + qsettings.value( + s.KEYBOARD + "/" + s.MOVE_LEFT, + consts.DEFAULT_MOVE_LEFT_KEY, + ), + ), + ( + s.MOVE_RIGHT, + qsettings.value( + s.KEYBOARD + "/" + s.MOVE_RIGHT, + consts.DEFAULT_MOVE_RIGHT_KEY, + ), + ), + ( + s.ROTATE_CLOCKWISE, + qsettings.value( + s.KEYBOARD + "/" + s.ROTATE_CLOCKWISE, + consts.DEFAULT_ROTATE_CLOCKWISE_KEY, + ), + ), + ( + s.ROTATE_COUNTERCLOCKWISE, + qsettings.value( + s.KEYBOARD + "/" + s.ROTATE_COUNTERCLOCKWISE, + consts.DEFAULT_ROTATE_COUNTERCLOCKWISE_KEY, + ), + ), + ( + s.SOFT_DROP, + qsettings.value( + s.KEYBOARD + "/" + s.SOFT_DROP, + consts.DEFAULT_SOFT_DROP_KEY, + ), + ), + ( + s.HARD_DROP, + qsettings.value( + s.KEYBOARD + "/" + s.HARD_DROP, + consts.DEFAULT_HARD_DROP_KEY, + ), + ), + ( + s.HOLD, + qsettings.value( + s.KEYBOARD + "/" + s.HOLD, consts.DEFAULT_HOLD_KEY + ), + ), + ( + s.PAUSE, + qsettings.value( + s.KEYBOARD + "/" + s.PAUSE, consts.DEFAULT_PAUSE_KEY + ), + ), + ] + ), + ), + ( + s.DELAYS, + collections.OrderedDict( + [ + ( + s.AUTO_SHIFT_DELAY, + int( + qsettings.value( + s.DELAYS + "/" + s.AUTO_SHIFT_DELAY, + consts.DEFAULT_AUTO_SHIFT_DELAY, + ) + ), + ), + ( + s.AUTO_REPEAT_RATE, + int( + qsettings.value( + s.DELAYS + "/" + s.AUTO_REPEAT_RATE, + consts.DEFAULT_AUTO_REPEAT_RATE, + ) + ), + ), + ] + ), + ), + ( + s.SOUND, + collections.OrderedDict( + [ + ( + s.MUSIC_VOLUME, + int( + qsettings.value( + s.SOUND + "/" + s.MUSIC_VOLUME, + consts.DEFAUT_MUSIC_VOLUME, + ) + ), + ), + ( + s.SFX_VOLUME, + int( + qsettings.value( + s.SOUND + "/" + s.SFX_VOLUME, + consts.DEFAULT_SFX_VOLUME, + ) + ), + ), + ] + ), + ), + ( + s.OTHER, + collections.OrderedDict( + [ + ( + s.GHOST, + bool( + qsettings.value( + s.OTHER + "/" + s.GHOST, + consts.DEFAULT_SHOW_GHOST, + ) + ), + ), + ( + s.SHOW_NEXT_QUEUE, + bool( + qsettings.value( + s.OTHER + "/" + s.SHOW_NEXT_QUEUE, + consts.DEFAULT_SHOW_NEXT_QUEUE, + ) + ), + ), + ( + s.HOLD_ENABLED, + bool( + qsettings.value( + s.OTHER + "/" + s.HOLD_ENABLED, + consts.DEFAULT_HOLD_ENABLED, + ) + ), + ), + ] + ), + ), + ] + ) + + def menuBar(self): + menu = super().menuBar() + + new_game_action = QtWidgets.QAction(self.tr("&New game"), self) + new_game_action.triggered.connect(self.frames.new_game) + menu.addAction(new_game_action) + + settings_action = QtWidgets.QAction(self.tr("&Settings"), self) + settings_action.triggered.connect(self.show_settings_dialog) + menu.addAction(settings_action) + + about_action = QtWidgets.QAction(self.tr("&About"), self) + about_action.triggered.connect(self.about) + menu.addAction(about_action) + return menu + + def show_settings_dialog(self): + SettingsDialog(self).exec_() + + self.frames.apply_settings() + + def about(self): + QtWidgets.QMessageBox.about( + self, + __title__, + self.tr( + """Tetris® clone by Adrien Malingrey + +Tetris Game Design by Alekseï Pajitnov +Graphism inspired by Tetris Effect +Window style sheet: qdarkstyle by Colin Duquesnoy +Fonts by Markus Koellmann, Peter Wiegel +Images from: +OpenGameArt.org by beren77, Duion +Pexels.com by Min An, Jaymantri, Felix Mittermeier +Pixabay.com by LoganArt +Pixnio.com by Adrian Pelletier +Unsplash.com by Aron, Patrick Fore, Ilnur Kalimullin, Gabriel Garcia Marengo, Adnanta Raharja +StockSnap.io by Nathan Anderson, José Ignacio Pompé +Musics from ocremix.org by: +CheDDer Nardz, djpretzel, MkVaff, Sir_NutS, R3FORGED, Sir_NutS +Sound effects made with voc-one by Simple-Media""" + ), + ) + if self.frames.playing: + self.frames.pause(False) + + def closeEvent(self, event): + if self.frames.playing: + answer = QtWidgets.QMessageBox.question( + self, + self.tr("Quit game?"), + self.tr("A game is in progress.\nDo you want to abord it?"), + QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, + QtWidgets.QMessageBox.Cancel, + ) + if answer == QtWidgets.QMessageBox.Cancel: + event.ignore() + self.frames.pause(False) + return + + self.frames.music.stop() + + # Save settings + qsettings.setValue(self.tr("High score"), self.stats.high_score) + qsettings.setValue("WindowGeometry", self.saveGeometry()) + qsettings.setValue("WindowState", int(self.windowState())) + diff --git a/point.py b/source/point.py similarity index 86% rename from point.py rename to source/point.py index 71f857c..e83cfce 100644 --- a/point.py +++ b/source/point.py @@ -1,48 +1,48 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -from consts import CLOCKWISE -from qt5 import QtCore -from propertize import propertize, rename_attributes, snake_case - - -@propertize("", "set_") -@rename_attributes(snake_case) -class Point(QtCore.QPoint): - """ - Point of coordinates (x, y) - """ - - def rotate(self, center, direction=CLOCKWISE): - """ Returns the Point image of the rotation of self - through 90° CLOKWISE or COUNTERCLOCKWISE around center""" - if self == center: - return self - - p = self - center - p = Point(-direction * p.y, direction * p.x) - p += center - return p - - def __add__(self, o): - return Point(self.x + o.x, self.y + o.y) - - def __sub__(self, o): - return Point(self.x - o.x, self.y - o.y) - - def __mul__(self, k): - return Point(k * self.x, k * self.y) - - def __truediv__(self, k): - return Point(self.x / k, self.y / k) - - __radd__ = __add__ - __rsub__ = __sub__ - __rmul__ = __mul__ - __rtruediv__ = __truediv__ - - def __repr__(self): - return "Point({}, {})".format(self.x, self.y) - - __str__ = __repr__ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from .consts import CLOCKWISE +from .qt5 import QtCore +from .propertize import propertize, rename_attributes, snake_case + + +@propertize("", "set_") +@rename_attributes(snake_case) +class Point(QtCore.QPoint): + """ + Point of coordinates (x, y) + """ + + def rotate(self, center, direction=CLOCKWISE): + """ Returns the Point image of the rotation of self + through 90° CLOKWISE or COUNTERCLOCKWISE around center""" + if self == center: + return self + + p = self - center + p = Point(-direction * p.y, direction * p.x) + p += center + return p + + def __add__(self, o): + return Point(self.x + o.x, self.y + o.y) + + def __sub__(self, o): + return Point(self.x - o.x, self.y - o.y) + + def __mul__(self, k): + return Point(k * self.x, k * self.y) + + def __truediv__(self, k): + return Point(self.x / k, self.y / k) + + __radd__ = __add__ + __rsub__ = __sub__ + __rmul__ = __mul__ + __rtruediv__ = __truediv__ + + def __repr__(self): + return "Point({}, {})".format(self.x, self.y) + + __str__ = __repr__ diff --git a/propertize/__init__.py b/source/propertize/__init__.py similarity index 100% rename from propertize/__init__.py rename to source/propertize/__init__.py diff --git a/propertize/convert_string.py b/source/propertize/convert_string.py similarity index 100% rename from propertize/convert_string.py rename to source/propertize/convert_string.py diff --git a/propertize/propertize.py b/source/propertize/propertize.py similarity index 100% rename from propertize/propertize.py rename to source/propertize/propertize.py diff --git a/propertize/rename_attributes.py b/source/propertize/rename_attributes.py similarity index 100% rename from propertize/rename_attributes.py rename to source/propertize/rename_attributes.py diff --git a/qt5.py b/source/qt5.py similarity index 96% rename from qt5.py rename to source/qt5.py index 39b8845..5343e03 100644 --- a/qt5.py +++ b/source/qt5.py @@ -1,29 +1,29 @@ -# -*- coding: utf-8 -*- - - -import sys -import os - -try: - from PyQt5 import QtWidgets, QtCore, QtGui, QtMultimedia -except ImportError as pyqt5_error: - try: - from PySide2 import QtWidgets, QtCore, QtGui, QtMultimedia - except ImportError as pyside2_error: - sys.exit( - "This program require a Qt5 library.\n" - "You can install PyQt5 (recommended) :\n" - " pip3 install --user PyQt5\n" - " pip3 install --user qdarkstyle\n" - "or PySide2 :\n" - " pip3 install --user PySide2\n" - + pyqt5_error.msg - + "\n" - + pyside2_error.msg - ) - else: - os.environ["QT_API"] = "pyside2" -else: - os.environ["QT_API"] = "pyqt5" - QtCore.Signal = QtCore.pyqtSignal +# -*- coding: utf-8 -*- + + +import sys +import os + +try: + from PyQt5 import QtWidgets, QtCore, QtGui, QtMultimedia +except ImportError as pyqt5_error: + try: + from PySide2 import QtWidgets, QtCore, QtGui, QtMultimedia + except ImportError as pyside2_error: + sys.exit( + "This program require a Qt5 library.\n" + "You can install PyQt5 (recommended) :\n" + " pip3 install --user PyQt5\n" + " pip3 install --user qdarkstyle\n" + "or PySide2 :\n" + " pip3 install --user PySide2\n" + + pyqt5_error.msg + + "\n" + + pyside2_error.msg + ) + else: + os.environ["QT_API"] = "pyside2" +else: + os.environ["QT_API"] = "pyqt5" + QtCore.Signal = QtCore.pyqtSignal \ No newline at end of file diff --git a/tetromino.py b/source/tetromino.py similarity index 96% rename from tetromino.py rename to source/tetromino.py index 9b33f15..72c20c5 100644 --- a/tetromino.py +++ b/source/tetromino.py @@ -1,422 +1,422 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - - -import random - -import consts -from consts import L, R, U, D, CLOCKWISE, COUNTERCLOCKWISE -from point import Point -from qt5 import QtCore, QtGui - - -class Block: - """ - Mino or block - Mino : A single square-shaped building block of a shape called a Tetrimino. - Four Minos arranged into any of their various connected patterns is known as a Tetrimino - Block : A single block locked in a cell in the Grid - """ - - # Colors - BORDER_COLOR = consts.BLOCK_BORDER_COLOR - FILL_COLOR = consts.BLOCK_FILL_COLOR - GLOWING_BORDER_COLOR = consts.BLOCK_GLOWING_BORDER_COLOR - GLOWING_FILL_COLOR = consts.BLOCK_GLOWING_FILL_COLOR - LIGHT_COLOR = consts.BLOCK_LIGHT_COLOR - TRANSPARENT = consts.BLOCK_TRANSPARENT - GLOWING = consts.BLOCK_GLOWING - - side = consts.BLOCK_INITIAL_SIDE - - def __init__(self, coord, trail=0): - self.coord = coord - self.trail = trail - self.border_color = self.BORDER_COLOR - self.fill_color = self.FILL_COLOR - self.glowing = self.GLOWING - - def paint(self, painter, top_left_corner, spotlight): - p = top_left_corner + self.coord * Block.side - block_center = Point(Block.side / 2, Block.side / 2) - self.center = p + block_center - spotlight = top_left_corner + Block.side * spotlight + block_center - self.glint = 0.15 * spotlight + 0.85 * self.center - - if self.trail: - start = ( - top_left_corner + (self.coord + Point(0, self.trail * U)) * Block.side - ) - stop = top_left_corner + (self.coord + Point(0, 2 * D)) * Block.side - fill = QtGui.QLinearGradient(start, stop) - fill.setColorAt(0, self.LIGHT_COLOR) - fill.setColorAt(1, self.GLOWING_FILL_COLOR) - painter.setBrush(fill) - painter.setPen(QtCore.Qt.NoPen) - painter.drawRoundedRect( - start.x, - start.y, - Block.side, - Block.side * (1 + self.trail), - 20, - 20, - QtCore.Qt.RelativeSize, - ) - - if self.glowing: - fill = QtGui.QRadialGradient(self.center, self.glowing * Block.side) - fill.setColorAt(0, self.TRANSPARENT) - fill.setColorAt(0.5 / self.glowing, self.LIGHT_COLOR) - fill.setColorAt(1, self.TRANSPARENT) - painter.setBrush(QtGui.QBrush(fill)) - painter.setPen(QtCore.Qt.NoPen) - painter.drawEllipse( - self.center.x - self.glowing * Block.side, - self.center.y - self.glowing * Block.side, - 2 * self.glowing * Block.side, - 2 * self.glowing * Block.side, - ) - - painter.setBrush(self.brush()) - painter.setPen(self.pen()) - painter.drawRoundedRect( - p.x + 1, - p.y + 1, - Block.side - 2, - Block.side - 2, - 20, - 20, - QtCore.Qt.RelativeSize, - ) - - def brush(self): - if self.fill_color is None: - return QtCore.Qt.NoBrush - - fill = QtGui.QRadialGradient(self.glint, 1.5 * Block.side) - fill.setColorAt(0, self.fill_color.lighter()) - fill.setColorAt(1, self.fill_color) - return QtGui.QBrush(fill) - - def pen(self): - if self.border_color is None: - return QtCore.Qt.NoPen - - border = QtGui.QRadialGradient(self.glint, Block.side) - border.setColorAt(0, self.border_color.lighter()) - border.setColorAt(1, self.border_color.darker()) - return QtGui.QPen(QtGui.QBrush(border), 1, join=QtCore.Qt.RoundJoin) - - def shine(self, glowing=2, delay=None): - self.border_color = Block.GLOWING_BORDER_COLOR - self.fill_color = Block.GLOWING_FILL_COLOR - self.glowing = glowing - if delay: - QtCore.QTimer.singleShot(delay, self.fade) - - def fade(self): - self.border_color = Block.BORDER_COLOR - self.fill_color = Block.FILL_COLOR - self.glowing = 0 - self.trail = 0 - - -class GhostBlock(Block): - """ - Mino of the ghost piece - """ - - BORDER_COLOR = consts.GHOST_BLOCK_BORDER_COLOR - FILL_COLOR = consts.GHOST_BLOCK_FILL_COLOR - GLOWING_FILL_COLOR = consts.GHOST_BLOCK_GLOWING_FILL_COLOR - GLOWING = consts.GHOST_BLOCK_GLOWING - - -class MetaTetro(type): - """ - Save the different shapes of Tetrominoes - """ - - def __init__(cls, name, bases, dico): - type.__init__(cls, name, bases, dico) - Tetromino.classes.append(cls) - Tetromino.nb_classes += 1 - - -class Tetromino: - """ - Geometric Tetris® shape formed by four Minos connected along their sides. - A total of seven possible Tetriminos can be made using four Minos. - """ - - COORDS = NotImplemented - SUPER_ROTATION_SYSTEM = ( - { - COUNTERCLOCKWISE: ((0, 0), (R, 0), (R, U), (0, 2 * D), (R, 2 * D)), - CLOCKWISE: ((0, 0), (L, 0), (L, U), (0, 2 * D), (L, 2 * D)), - }, - { - COUNTERCLOCKWISE: ((0, 0), (R, 0), (R, D), (0, 2 * U), (R, 2 * U)), - CLOCKWISE: ((0, 0), (R, 0), (R, D), (0, 2 * U), (R, 2 * U)), - }, - { - COUNTERCLOCKWISE: ((0, 0), (L, 0), (L, U), (0, 2 * D), (L, 2 * D)), - CLOCKWISE: ((0, 0), (R, 0), (R, U), (0, 2 * D), (R, 2 * D)), - }, - { - COUNTERCLOCKWISE: ((0, 0), (L, 0), (L, D), (0, 2 * U), (L, 2 * U)), - CLOCKWISE: ((0, 0), (L, 0), (L, D), (0, 2 * D), (L, 2 * U)), - }, - ) - - classes = [] - nb_classes = 0 - random_bag = [] - - def __new__(cls): - """ - Return a Tetromino using the 7-bag Random Generator - Tetris uses a “bag” system to determine the sequence of Tetriminos - that appear during game play. - This system allows for equal distribution among the seven Tetriminos. - The seven different Tetriminos are placed into a virtual bag, - then shuffled into a random order. - This order is the sequence that the bag “feeds” the Next Queue. - Every time a new Tetrimino is generated and starts its fall within the Matrix, - the Tetrimino at the front of the line in the bag is placed at the end of the Next Queue, - pushing all Tetriminos in the Next Queue forward by one. - The bag is refilled and reshuffled once it is empty. - """ - if not cls.random_bag: - cls.random_bag = random.sample(cls.classes, cls.nb_classes) - return super().__new__(cls.random_bag.pop()) - - def __init__(self): - self.orientation = 0 - self.t_spin = "" - - def insert_into(self, matrix, position): - self.matrix = matrix - self.minoes = tuple(Block(Point(*coord) + position) for coord in self.COORDS) - - def _try_movement(self, next_coords_generator, trail=0, update=True): - """ - Test if self can fit in the Grid with new coordinates, - i.e. all cells are empty. - If it can, change self's coordinates and return True. - Else, make no changes and return False - Update the Grid if there is no drop trail - """ - futures_coords = [] - for p in next_coords_generator: - if not self.matrix.is_empty_cell(p): - return False - futures_coords.append(p) - - for block, future_coord in zip(self.minoes, futures_coords): - block.coord = future_coord - block.trail = trail - if update: - self.matrix.update() - return True - - def move(self, horizontally, vertically, trail=0, update=True): - """ - Try to translate self horizontally or vertically - The Tetrimino in play falls from just above the Skyline one cell at a time, - and moves left and right one cell at a time. - Each Mino of a Tetrimino “snaps” to the appropriate cell position at the completion of a move, - although intermediate Tetrimino movement appears smooth. - Only right, left, and downward movement are allowed. - Movement into occupied cells and Matrix walls and floors is not allowed - Update the Grid if there is no drop trail - """ - return self._try_movement( - (block.coord + Point(horizontally, vertically) for block in self.minoes), - trail, - update - ) - - def rotate(self, direction=CLOCKWISE): - """ - Try to rotate self through 90° CLOCKWISE or COUNTERCLOCKWISE around its center - Tetriminos can rotate clockwise and counterclockwise using the Super Rotation System. - This system allows Tetrimino rotation in situations that - the original Classic Rotation System did not allow, - such as rotating against walls. - Each time a rotation button is pressed, - the Tetrimino in play rotates 90 degrees in the clockwise or counterclockwise direction. - Rotation can be performed while the Tetrimino is Auto-Repeating left or right. - There is no Auto-Repeat for rotation itself. - """ - rotated_coords = tuple( - mino.coord.rotate(self.minoes[0].coord, direction) for mino in self.minoes - ) - - for movement in self.SUPER_ROTATION_SYSTEM[self.orientation][direction]: - if self._try_movement(coord + Point(*movement) for coord in rotated_coords): - self.orientation = (self.orientation + direction) % 4 - return True - return False - - def soft_drop(self): - """ - Causes the Tetrimino to drop at an accelerated rate (s.AUTO_REPEAT_RATE) - from its current location - """ - return self.move(0, D, trail=1) - - def hard_drop(self, show_trail=True, update=True): - """ - Causes the Tetrimino in play to drop straight down instantly from its - current location and Lock Down on the first Surface it lands on. - It does not allow for further player manipulation of the Tetrimino in play. - """ - trail = 0 - while self.move(0, D, trail=trail, update=update): - if show_trail: - trail += 1 - return trail - - -class TetroI(Tetromino, metaclass=MetaTetro): - """ - Tetromino shaped like a capital I - four minoes in a straight line - """ - - COORDS = (L, 0), (2 * L, 0), (0, 0), (R, 0) - SUPER_ROTATION_SYSTEM = ( - { - COUNTERCLOCKWISE: ((0, D), (L, D), (2 * R, D), (L, U), (2 * R, 2 * D)), - CLOCKWISE: ((R, 0), (L, 0), (2 * R, 0), (L, D), (2 * R, 2 * U)), - }, - { - COUNTERCLOCKWISE: ((L, 0), (R, 0), (2 * L, 0), (R, U), (2 * L, 2 * D)), - CLOCKWISE: ((0, D), (L, D), (2 * R, D), (L, U), (2 * R, 2 * D)), - }, - { - COUNTERCLOCKWISE: ((0, U), (R, U), (2 * L, U), (R, D), (2 * L, 2 * U)), - CLOCKWISE: ((L, 0), (R, 0), (2 * L, 0), (R, U), (2 * L, 2 * D)), - }, - { - COUNTERCLOCKWISE: ((R, 0), (L, 0), (2 * R, 0), (L, D), (2 * R, 2 * U)), - CLOCKWISE: ((0, U), (R, U), (2 * L, U), (R, D), (2 * L, 2 * U)), - }, - ) - - -class TetroT(Tetromino, metaclass=MetaTetro): - """ - Tetromino shaped like a capital T - a row of three minoes with one added above the center - Can perform a T-Spin - """ - - COORDS = (0, 0), (L, 0), (0, U), (R, 0) - T_SLOT_A = ((L, U), (R, U), (R, D), (L, D)) - T_SLOT_B = ((R, U), (R, D), (L, D), (L, U)) - T_SLOT_C = ((L, D), (L, U), (R, U), (R, D)) - T_SLOT_D = ((R, D), (L, D), (L, U), (R, U)) - - def __init__(self): - super().__init__() - - def rotate(self, direction=CLOCKWISE): - """ - Detects T-Spins: - this action can be achieved by first landing a T-Tetrimino, - and before it Locks Down, rotating it in a T-Slot - (any Block formation such that when the T-Tetrimino is spun into it, - any three of the four cells diagonally adjacent to the center of self - are occupied by existing Blocks.) - """ - rotated = super().rotate(direction) - if rotated: - center = self.minoes[0].coord - pa = center + Point(*self.T_SLOT_A[self.orientation]) - pb = center + Point(*self.T_SLOT_B[self.orientation]) - pc = center + Point(*self.T_SLOT_C[self.orientation]) - pd = center + Point(*self.T_SLOT_D[self.orientation]) - - a = not self.matrix.is_empty_cell(pa) - b = not self.matrix.is_empty_cell(pb) - c = not self.matrix.is_empty_cell(pc) - d = not self.matrix.is_empty_cell(pd) - - if (a and b) and (c or d): - if c: - pe = (pa + pc) / 2 - elif d: - pe = (pb + pd) / 2 - if not self.matrix.is_empty_cell(pe): - self.t_spin = "T-Spin" - elif (a or b) and (c and d): - self.t_spin = "Mini T-Spin" - return rotated - - -class TetroZ(Tetromino, metaclass=MetaTetro): - """ - Tetromino shaped like a capital Z - two stacked horizontal dominoes with the top one offset to the left - """ - - COORDS = (0, 0), (L, U), (0, U), (R, 0) - - -class TetroS(Tetromino, metaclass=MetaTetro): - """ - Tetromino shaped like a capital S - two stacked horizontal dominoes with the top one offset to the right - """ - - COORDS = (0, 0), (0, U), (L, 0), (R, U) - - -class TetroL(Tetromino, metaclass=MetaTetro): - """ - Tetromino shaped like a capital L - a row of three minoes with one added above the right side - """ - - COORDS = (0, 0), (L, 0), (R, 0), (R, U) - - -class TetroJ(Tetromino, metaclass=MetaTetro): - """ - Tetromino shaped like a capital J - a row of three minoes with one added above the left side - """ - - COORDS = (0, 0), (L, U), (L, 0), (R, 0) - - -class TetroO(Tetromino, metaclass=MetaTetro): - """ - Square shape - four minoes in a 2×2 square. - """ - - COORDS = (0, 0), (L, 0), (0, U), (L, U) - - def rotate(self, direction=1): - """ irrelevant """ - return False - - -class GhostPiece(Tetromino): - """ - A graphical representation of where the Tetrimino in play will come to rest - if it is dropped from its current position. - """ - - def __new__(cls, piece): - return object.__new__(cls) - - def __init__(self, piece): - self.matrix = piece.matrix - self.minoes = tuple( - GhostBlock(Point(mino.coord.x, mino.coord.y)) for mino in piece.minoes - ) - self.hard_drop(show_trail=False, update=False) +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import random + +from . import consts +from .consts import L, R, U, D, CLOCKWISE, COUNTERCLOCKWISE +from .point import Point +from .qt5 import QtCore, QtGui + + +class Block: + """ + Mino or block + Mino : A single square-shaped building block of a shape called a Tetrimino. + Four Minos arranged into any of their various connected patterns is known as a Tetrimino + Block : A single block locked in a cell in the Grid + """ + + # Colors + BORDER_COLOR = consts.BLOCK_BORDER_COLOR + FILL_COLOR = consts.BLOCK_FILL_COLOR + GLOWING_BORDER_COLOR = consts.BLOCK_GLOWING_BORDER_COLOR + GLOWING_FILL_COLOR = consts.BLOCK_GLOWING_FILL_COLOR + LIGHT_COLOR = consts.BLOCK_LIGHT_COLOR + TRANSPARENT = consts.BLOCK_TRANSPARENT + GLOWING = consts.BLOCK_GLOWING + + side = consts.BLOCK_INITIAL_SIDE + + def __init__(self, coord, trail=0): + self.coord = coord + self.trail = trail + self.border_color = self.BORDER_COLOR + self.fill_color = self.FILL_COLOR + self.glowing = self.GLOWING + + def paint(self, painter, top_left_corner, spotlight): + p = top_left_corner + self.coord * Block.side + block_center = Point(Block.side / 2, Block.side / 2) + self.center = p + block_center + spotlight = top_left_corner + Block.side * spotlight + block_center + self.glint = 0.15 * spotlight + 0.85 * self.center + + if self.trail: + start = ( + top_left_corner + (self.coord + Point(0, self.trail * U)) * Block.side + ) + stop = top_left_corner + (self.coord + Point(0, 2 * D)) * Block.side + fill = QtGui.QLinearGradient(start, stop) + fill.setColorAt(0, self.LIGHT_COLOR) + fill.setColorAt(1, self.GLOWING_FILL_COLOR) + painter.setBrush(fill) + painter.setPen(QtCore.Qt.NoPen) + painter.drawRoundedRect( + start.x, + start.y, + Block.side, + Block.side * (1 + self.trail), + 20, + 20, + QtCore.Qt.RelativeSize, + ) + + if self.glowing: + fill = QtGui.QRadialGradient(self.center, self.glowing * Block.side) + fill.setColorAt(0, self.TRANSPARENT) + fill.setColorAt(0.5 / self.glowing, self.LIGHT_COLOR) + fill.setColorAt(1, self.TRANSPARENT) + painter.setBrush(QtGui.QBrush(fill)) + painter.setPen(QtCore.Qt.NoPen) + painter.drawEllipse( + self.center.x - self.glowing * Block.side, + self.center.y - self.glowing * Block.side, + 2 * self.glowing * Block.side, + 2 * self.glowing * Block.side, + ) + + painter.setBrush(self.brush()) + painter.setPen(self.pen()) + painter.drawRoundedRect( + p.x + 1, + p.y + 1, + Block.side - 2, + Block.side - 2, + 20, + 20, + QtCore.Qt.RelativeSize, + ) + + def brush(self): + if self.fill_color is None: + return QtCore.Qt.NoBrush + + fill = QtGui.QRadialGradient(self.glint, 1.5 * Block.side) + fill.setColorAt(0, self.fill_color.lighter()) + fill.setColorAt(1, self.fill_color) + return QtGui.QBrush(fill) + + def pen(self): + if self.border_color is None: + return QtCore.Qt.NoPen + + border = QtGui.QRadialGradient(self.glint, Block.side) + border.setColorAt(0, self.border_color.lighter()) + border.setColorAt(1, self.border_color.darker()) + return QtGui.QPen(QtGui.QBrush(border), 1, join=QtCore.Qt.RoundJoin) + + def shine(self, glowing=2, delay=None): + self.border_color = Block.GLOWING_BORDER_COLOR + self.fill_color = Block.GLOWING_FILL_COLOR + self.glowing = glowing + if delay: + QtCore.QTimer.singleShot(delay, self.fade) + + def fade(self): + self.border_color = Block.BORDER_COLOR + self.fill_color = Block.FILL_COLOR + self.glowing = 0 + self.trail = 0 + + +class GhostBlock(Block): + """ + Mino of the ghost piece + """ + + BORDER_COLOR = consts.GHOST_BLOCK_BORDER_COLOR + FILL_COLOR = consts.GHOST_BLOCK_FILL_COLOR + GLOWING_FILL_COLOR = consts.GHOST_BLOCK_GLOWING_FILL_COLOR + GLOWING = consts.GHOST_BLOCK_GLOWING + + +class MetaTetro(type): + """ + Save the different shapes of Tetrominoes + """ + + def __init__(cls, name, bases, dico): + type.__init__(cls, name, bases, dico) + Tetromino.classes.append(cls) + Tetromino.nb_classes += 1 + + +class Tetromino: + """ + Geometric Tetris® shape formed by four Minos connected along their sides. + A total of seven possible Tetriminos can be made using four Minos. + """ + + COORDS = NotImplemented + SUPER_ROTATION_SYSTEM = ( + { + COUNTERCLOCKWISE: ((0, 0), (R, 0), (R, U), (0, 2 * D), (R, 2 * D)), + CLOCKWISE: ((0, 0), (L, 0), (L, U), (0, 2 * D), (L, 2 * D)), + }, + { + COUNTERCLOCKWISE: ((0, 0), (R, 0), (R, D), (0, 2 * U), (R, 2 * U)), + CLOCKWISE: ((0, 0), (R, 0), (R, D), (0, 2 * U), (R, 2 * U)), + }, + { + COUNTERCLOCKWISE: ((0, 0), (L, 0), (L, U), (0, 2 * D), (L, 2 * D)), + CLOCKWISE: ((0, 0), (R, 0), (R, U), (0, 2 * D), (R, 2 * D)), + }, + { + COUNTERCLOCKWISE: ((0, 0), (L, 0), (L, D), (0, 2 * U), (L, 2 * U)), + CLOCKWISE: ((0, 0), (L, 0), (L, D), (0, 2 * D), (L, 2 * U)), + }, + ) + + classes = [] + nb_classes = 0 + random_bag = [] + + def __new__(cls): + """ + Return a Tetromino using the 7-bag Random Generator + Tetris uses a “bag” system to determine the sequence of Tetriminos + that appear during game play. + This system allows for equal distribution among the seven Tetriminos. + The seven different Tetriminos are placed into a virtual bag, + then shuffled into a random order. + This order is the sequence that the bag “feeds” the Next Queue. + Every time a new Tetrimino is generated and starts its fall within the Matrix, + the Tetrimino at the front of the line in the bag is placed at the end of the Next Queue, + pushing all Tetriminos in the Next Queue forward by one. + The bag is refilled and reshuffled once it is empty. + """ + if not cls.random_bag: + cls.random_bag = random.sample(cls.classes, cls.nb_classes) + return super().__new__(cls.random_bag.pop()) + + def __init__(self): + self.orientation = 0 + self.t_spin = "" + + def insert_into(self, matrix, position): + self.matrix = matrix + self.minoes = tuple(Block(Point(*coord) + position) for coord in self.COORDS) + + def _try_movement(self, next_coords_generator, trail=0, update=True): + """ + Test if self can fit in the Grid with new coordinates, + i.e. all cells are empty. + If it can, change self's coordinates and return True. + Else, make no changes and return False + Update the Grid if there is no drop trail + """ + futures_coords = [] + for p in next_coords_generator: + if not self.matrix.is_empty_cell(p): + return False + futures_coords.append(p) + + for block, future_coord in zip(self.minoes, futures_coords): + block.coord = future_coord + block.trail = trail + if update: + self.matrix.update() + return True + + def move(self, horizontally, vertically, trail=0, update=True): + """ + Try to translate self horizontally or vertically + The Tetrimino in play falls from just above the Skyline one cell at a time, + and moves left and right one cell at a time. + Each Mino of a Tetrimino “snaps” to the appropriate cell position at the completion of a move, + although intermediate Tetrimino movement appears smooth. + Only right, left, and downward movement are allowed. + Movement into occupied cells and Matrix walls and floors is not allowed + Update the Grid if there is no drop trail + """ + return self._try_movement( + (block.coord + Point(horizontally, vertically) for block in self.minoes), + trail, + update + ) + + def rotate(self, direction=CLOCKWISE): + """ + Try to rotate self through 90° CLOCKWISE or COUNTERCLOCKWISE around its center + Tetriminos can rotate clockwise and counterclockwise using the Super Rotation System. + This system allows Tetrimino rotation in situations that + the original Classic Rotation System did not allow, + such as rotating against walls. + Each time a rotation button is pressed, + the Tetrimino in play rotates 90 degrees in the clockwise or counterclockwise direction. + Rotation can be performed while the Tetrimino is Auto-Repeating left or right. + There is no Auto-Repeat for rotation itself. + """ + rotated_coords = tuple( + mino.coord.rotate(self.minoes[0].coord, direction) for mino in self.minoes + ) + + for movement in self.SUPER_ROTATION_SYSTEM[self.orientation][direction]: + if self._try_movement(coord + Point(*movement) for coord in rotated_coords): + self.orientation = (self.orientation + direction) % 4 + return True + return False + + def soft_drop(self): + """ + Causes the Tetrimino to drop at an accelerated rate (s.AUTO_REPEAT_RATE) + from its current location + """ + return self.move(0, D, trail=1) + + def hard_drop(self, show_trail=True, update=True): + """ + Causes the Tetrimino in play to drop straight down instantly from its + current location and Lock Down on the first Surface it lands on. + It does not allow for further player manipulation of the Tetrimino in play. + """ + trail = 0 + while self.move(0, D, trail=trail, update=update): + if show_trail: + trail += 1 + return trail + + +class TetroI(Tetromino, metaclass=MetaTetro): + """ + Tetromino shaped like a capital I + four minoes in a straight line + """ + + COORDS = (L, 0), (2 * L, 0), (0, 0), (R, 0) + SUPER_ROTATION_SYSTEM = ( + { + COUNTERCLOCKWISE: ((0, D), (L, D), (2 * R, D), (L, U), (2 * R, 2 * D)), + CLOCKWISE: ((R, 0), (L, 0), (2 * R, 0), (L, D), (2 * R, 2 * U)), + }, + { + COUNTERCLOCKWISE: ((L, 0), (R, 0), (2 * L, 0), (R, U), (2 * L, 2 * D)), + CLOCKWISE: ((0, D), (L, D), (2 * R, D), (L, U), (2 * R, 2 * D)), + }, + { + COUNTERCLOCKWISE: ((0, U), (R, U), (2 * L, U), (R, D), (2 * L, 2 * U)), + CLOCKWISE: ((L, 0), (R, 0), (2 * L, 0), (R, U), (2 * L, 2 * D)), + }, + { + COUNTERCLOCKWISE: ((R, 0), (L, 0), (2 * R, 0), (L, D), (2 * R, 2 * U)), + CLOCKWISE: ((0, U), (R, U), (2 * L, U), (R, D), (2 * L, 2 * U)), + }, + ) + + +class TetroT(Tetromino, metaclass=MetaTetro): + """ + Tetromino shaped like a capital T + a row of three minoes with one added above the center + Can perform a T-Spin + """ + + COORDS = (0, 0), (L, 0), (0, U), (R, 0) + T_SLOT_A = ((L, U), (R, U), (R, D), (L, D)) + T_SLOT_B = ((R, U), (R, D), (L, D), (L, U)) + T_SLOT_C = ((L, D), (L, U), (R, U), (R, D)) + T_SLOT_D = ((R, D), (L, D), (L, U), (R, U)) + + def __init__(self): + super().__init__() + + def rotate(self, direction=CLOCKWISE): + """ + Detects T-Spins: + this action can be achieved by first landing a T-Tetrimino, + and before it Locks Down, rotating it in a T-Slot + (any Block formation such that when the T-Tetrimino is spun into it, + any three of the four cells diagonally adjacent to the center of self + are occupied by existing Blocks.) + """ + rotated = super().rotate(direction) + if rotated: + center = self.minoes[0].coord + pa = center + Point(*self.T_SLOT_A[self.orientation]) + pb = center + Point(*self.T_SLOT_B[self.orientation]) + pc = center + Point(*self.T_SLOT_C[self.orientation]) + pd = center + Point(*self.T_SLOT_D[self.orientation]) + + a = not self.matrix.is_empty_cell(pa) + b = not self.matrix.is_empty_cell(pb) + c = not self.matrix.is_empty_cell(pc) + d = not self.matrix.is_empty_cell(pd) + + if (a and b) and (c or d): + if c: + pe = (pa + pc) / 2 + elif d: + pe = (pb + pd) / 2 + if not self.matrix.is_empty_cell(pe): + self.t_spin = "T-Spin" + elif (a or b) and (c and d): + self.t_spin = "Mini T-Spin" + return rotated + + +class TetroZ(Tetromino, metaclass=MetaTetro): + """ + Tetromino shaped like a capital Z + two stacked horizontal dominoes with the top one offset to the left + """ + + COORDS = (0, 0), (L, U), (0, U), (R, 0) + + +class TetroS(Tetromino, metaclass=MetaTetro): + """ + Tetromino shaped like a capital S + two stacked horizontal dominoes with the top one offset to the right + """ + + COORDS = (0, 0), (0, U), (L, 0), (R, U) + + +class TetroL(Tetromino, metaclass=MetaTetro): + """ + Tetromino shaped like a capital L + a row of three minoes with one added above the right side + """ + + COORDS = (0, 0), (L, 0), (R, 0), (R, U) + + +class TetroJ(Tetromino, metaclass=MetaTetro): + """ + Tetromino shaped like a capital J + a row of three minoes with one added above the left side + """ + + COORDS = (0, 0), (L, U), (L, 0), (R, 0) + + +class TetroO(Tetromino, metaclass=MetaTetro): + """ + Square shape + four minoes in a 2×2 square. + """ + + COORDS = (0, 0), (L, 0), (0, U), (L, U) + + def rotate(self, direction=1): + """ irrelevant """ + return False + + +class GhostPiece(Tetromino): + """ + A graphical representation of where the Tetrimino in play will come to rest + if it is dropped from its current position. + """ + + def __new__(cls, piece): + return object.__new__(cls) + + def __init__(self, piece): + self.matrix = piece.matrix + self.minoes = tuple( + GhostBlock(Point(mino.coord.x, mino.coord.y)) for mino in piece.minoes + ) + self.hard_drop(show_trail=False, update=False)