LoginSignup
8
10

More than 5 years have passed since last update.

PyQt5チュートリアルのテトリスをMVC化

Last updated at Posted at 2019-01-27

ZetCodePyQt5チュートリアルにあるテトリスのソースコードを拝見したのですが、モデルが分離されておらず、気になってしまったので MVC(Model-View-Controller) 化に挑戦しました。

  • Model(データ) は表示に左右されない部分、PyQt5(GUI)は登場しない
  • Modelは大きく Tetrimino と Field の2つで構成、FacadeパターンでModelに統合
  • View(表示) と Controller(操作) で PyQt5を使用
  • チュートリアルではwidgetを継承して使用しているが、名前衝突を嫌って継承しないように変更

改善点などありましたらコメントお願いします。
何か参考になることがあれば幸いです。

tetrimino.py
class Tetrimino:
    """
    # python -m doctest -v tetrimino.py
    >>> len(tetriminos)
    7
    >>> print(tetrimino_names['Z'].rotate_unclockwise())
    ## 
     ##
    >>> print(tetrimino_names['S'].rotate_clockwise())
     ##
    ## 
    >>> print(tetrimino_names['I'].rotate_unclockwise())
    ####
    >>> print(tetrimino_names['T'])
    ###
     # 
    >>> print(tetrimino_names['O'])
    ##
    ##
    >>> tetrimino_names['O'] is tetrimino_names['O'].rotate_clockwise()
    True
    >>> print(tetrimino_names['L'].rotate_clockwise().rotate_clockwise())
    # 
    # 
    ##
    >>> print(tetrimino_names['J'].rotate_unclockwise().rotate_unclockwise())
     #
     #
    ##
    """

    def __init__(self, name, *offsets, rotate=True):
        self.name = name
        self.offsets = offsets
        self.can_rotate = rotate
        self.min_x = min(x for x, y in self)
        self.min_y = min(y for x, y in self)
        self.max_x = max(x for x, y in self)
        self.max_y = max(y for x, y in self)

    def __iter__(self):
        return iter((x, y) for x, y in self.offsets)

    def __str__(self):
        xs = range(self.min_x, self.max_x + 1)
        ys = range(self.min_y, self.max_y + 1)
        return '\n'.join(''.join(' #'[(x, y) in self.offsets] for x in xs)
                         for y in ys)

    def move(x, y):
        return Tetrimino(self.name, *self.offsets, rotate=self.can_rotate)

    def rotate_unclockwise(self):
        if not self.can_rotate:
            return self
        return Tetrimino(self.name, *((y, -x) for x, y in self.offsets))

    def rotate_clockwise(self):
        if not self.can_rotate:
            return self
        return Tetrimino(self.name, *((-y, x) for x, y in self.offsets))


tetriminos = (  # (+0, +0) is rotation center
    Tetrimino('Z', (+0, -1), (+0, +0), (-1, +0), (-1, +1)),
    Tetrimino('S', (+0, -1), (+0, +0), (+1, +0), (+1, +1)),
    Tetrimino('I', (+0, -1), (+0, +0), (+0, +1), (+0, +2)),
    Tetrimino('T', (-1, +0), (+0, +0), (+1, +0), (+0, +1)),
    Tetrimino('O', (+0, +0), (+1, +0), (+0, +1), (+1, +1), rotate=False),
    Tetrimino('L', (-1, -1), (+0, -1), (+0, +0), (+0, +1)),
    Tetrimino('J', (+1, -1), (+0, -1), (+0, +0), (+0, +1)),
)

tetrimino_names = {tetrimino.name: tetrimino for tetrimino in tetriminos}
field.py
class TetrisField:
    """
    # python -m doctest -v field.py
    >>> field = TetrisField(6, 4)
    >>> print(field)
    |      |
    |      |
    |      |
    |      |
    >>> field.put(((-1, -1), (0, -1), (-1, 0), (0, 0)), 1, 3)
    >>> print(field)
    |      |
    |      |
    |##    |
    |##    |
    >>> field.clear_complete_lines()
    0
    >>> field.can_put(((0, 0), (1, 0), (2, 0), (3, 0)), 2, 3)
    True
    >>> field.put(((0, 0), (1, 0), (2, 0), (3, 0)), 2, 3)
    >>> print(field)
    |      |
    |      |
    |##    |
    |######|
    >>> field.clear_complete_lines()
    1
    >>> print(field)
    |      |
    |      |
    |      |
    |##    |
    """

    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.tiles = [[None] * width for y in range(height)]
        self.wall = ((-width - 1, -height - 1), (width + 1, height + 1))


    def __str__(self):
        return '\n'.join('|' + ''.join('# '[tile is None] for tile in row) + '|'
                         for row in self.tiles)

    def __iter__(self):
        return iter(self.tiles)

    def __setitem__(self, coodinates, tile):
        x, y = coodinates
        if 0 <= x < self.width and 0 <= y < self.height:
            self.tiles[y][x] = tile

    def __getitem__(self, y_or_xy):
        if isinstance(y_or_xy, int):
            y = y_or_xy
            return self.tiles[y] if 0 <= y < self.height else ()
        x, y = y_or_xy
        if y < 0:
            return None
        elif 0 <= x < self.width and y < self.height:
            return self.tiles[y][x]
        else:
            return self.wall

    def copy(self):
        field = TetrisField(self.width, self.height)
        for y, tiles in enumerate(self):
            for x, tile in enumerate(tiles):
                field[x, y] = tile
        return field

    def can_put(self, tetrimino, x, y):
        tetrimino = tetrimino or self.wall
        return all(self[x + dx, y + dy] is None for dx, dy in tetrimino)

    def put(self, tetrimino, x, y):
        for dx, dy in tetrimino:
            self[x + dx, y + dy] = tetrimino

    def clear_complete_lines(self):
        for y, tiles in enumerate(self.tiles[:]):
            if all(tile is not None for tile in tiles):
                del self.tiles[y]
        clear_lines = self.height - len(self.tiles)
        if clear_lines > 0:
            self.tiles[:0] = [[None] * self.width for y in range(clear_lines)]
        return clear_lines
model.py
import random
from tetrimino import tetriminos, tetrimino_names
from field import TetrisField


class TetrisModel:
    """
    # python -m doctest -v model.py
    >>> model = TetrisModel(4, 6)
    >>> model.put(tetrimino_names['T'], 2, 0)
    True
    >>> model.drop_down()
    >>> print(model)
    |    |
    |    |
    |    |
    |    |
    | ###|
    |  # |
    >>> model.put(tetrimino_names['I'], 0, 0)
    True
    >>> model.drop_down()
    >>> print(model)
    |    |
    |    |
    |    |
    |#   |
    |#   |
    |# # |
    >>> model.score
    1
    """

    SCORE = (0, 1, 2, 4, 8)  # by clear lines

    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.field = TetrisField(width, height)
        self.tetrimino = None
        self.x = 0
        self.y = 0
        self.score = 0
        self.alive = True

    def __iter__(self):
        field = self.field.copy()
        self.tetrimino and field.put(self.tetrimino, self.x, self.y)
        return iter(field)

    def __str__(self):
        field = self.field.copy()
        self.tetrimino and field.put(self.tetrimino, self.x, self.y)
        return str(field)

    def is_alive(self):
        return self.alive

    def dead(self):
        self.alive = False

    def put_new_tetrimino(self):
        tetrimino = random.choice(tetriminos)
        x = self.field.width // 2
        for y in range(tetrimino.min_y, tetrimino.max_y + 1):
            if self.field.can_put(tetrimino, x, -y):
                self.put(tetrimino, x, -y)
                return
        self.dead()

    def put(self, tetrimino, x, y):
        if not self.field.can_put(tetrimino, x, y):
            return False
        self.tetrimino = tetrimino
        self.x = x
        self.y = y
        return True

    def replace(self, tetrimino):
        return self.put(tetrimino, self.x, self.y)

    def move(self, x, y):
        return self.tetrimino and self.put(self.tetrimino, x, y)

    def move_left(self):
        self.move(self.x - 1, self.y)

    def move_right(self):
        self.move(self.x + 1, self.y)

    def rotate_clockwise(self):
        self.tetrimino and self.replace(self.tetrimino.rotate_clockwise())

    def rotate_unclockwise(self):
        self.tetrimino and self.replace(self.tetrimino.rotate_unclockwise())

    def move_down(self):
        if self.tetrimino is None:
            return False
        if self.move(self.x, self.y + 1):
            return True
        self.fixed()
        return False

    def drop_down(self):
        while self.move_down(): pass

    def fixed(self):
        if self.tetrimino is None:
            return True
        self.field.put(self.tetrimino, self.x, self.y)
        self.tetrimino = None
        clear_lines = self.field.clear_complete_lines()
        self.score += self.SCORE[clear_lines]
        return False

    def step(self):
        if not self.is_alive():
            return
        if self.tetrimino:
            self.move_down()
        else:
            self.put_new_tetrimino()
qt5view.py
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QMainWindow, QFrame, QDesktopWidget
from PyQt5.QtGui import QPainter, QColor

TILE_COLORS = {
    'Z': QColor(0xCC6666),
    'S': QColor(0x66CC66),
    'I': QColor(0x6666CC),
    'T': QColor(0xCCCC66),
    'O': QColor(0xCC66CC),
    'L': QColor(0x66CCCC),
    'J': QColor(0xDAAA00),
}


class ModelView:

    def __init__(self, parent, model):
        self.model = model
        self.widget = QFrame(parent)
        self.widget.setFocusPolicy(Qt.StrongFocus)
        self.widget.paintEvent = self.draw

    def draw(self, event):
        width  = self.widget.contentsRect().width()  // self.model.width
        height = self.widget.contentsRect().height() // self.model.height
        tiles = ((x, y, tile)
                 for y, tiles in enumerate(self.model)
                 for x, tile in enumerate(tiles)
                 if tile is not None)
        for x, y, tile in tiles:
            color = TILE_COLORS[tile.name]
            self.drawTile(x * width, y * width, width, height, color)

    def drawTile(self, x, y, width, height, color):
        x2, y2 = x + width, y + height

        painter = QPainter(self.widget)
        painter.fillRect(x, y, width, height, color)

        painter.setPen(color.lighter())
        painter.drawLine(x, y, x2, y)
        painter.drawLine(x, y, x, y2)

        painter.setPen(color.darker())
        painter.drawLine(x + 1, y2 - 1, x2 - 1, y2 - 1)
        painter.drawLine(x2 - 1, y + 1, x2 - 1, y2 - 1)


class TetrisView:

    def __init__(self, model, width, height):
        self.widget = QMainWindow()
        self.model_view = ModelView(self.widget, model)
        self.status = self.widget.statusBar()
        self.widget.setWindowTitle('Tetris')
        self.widget.setCentralWidget(self.model_view.widget)
        self.widget.resize(width, height)
        self.centering()

    def centering(self):
        screen = QDesktopWidget().screenGeometry()
        size = self.widget.geometry()
        self.widget.move((screen.width() - size.width()) // 2,
                         (screen.height() - size.height()) // 2)

    def show(self):
        self.widget.show()

    def showStatus(self, message):
        self.status.showMessage(message)

    def update(self):
        self.model_view.widget.update()
        self.widget.update()

    def on_keyin(self, handler):
        # call handler(key) when key is pressed
        def hook(event):
            handler(event.key())
        self.widget.keyPressEvent = hook

    def on_close(self, handler):
        def closeEvent(event):
            handler()
        self.widget.closeEvent = closeEvent
qt5tetris.py
import sys
from PyQt5.QtCore import Qt, QObject, pyqtSignal, QBasicTimer
from PyQt5.QtWidgets import QApplication
from model import TetrisModel
from qt5view import TetrisView


class Tetris:
    SPEED = 300

    def __init__(self, app):
        self.app = app
        self.model = TetrisModel(width=10, height=20)
        self.view = TetrisView(self.model, width=180, height=380)
        self.status = Status()
        self.status.on(self.view.showStatus)
        self.key_map = self.make_key_map()
        self.view.on_keyin(self.keyin)
        self.view.on_close(self.quit)
        self.timer = Timer(self.step)
        self.is_started = False

    def make_key_map(self):
        return {
            Qt.Key_Q:     self.quit,
            Qt.Key_P:     self.pause,
            Qt.Key_Left:  self.model.move_left,
            Qt.Key_Right: self.model.move_right,
            Qt.Key_Up:    self.model.rotate_clockwise,
            Qt.Key_Down:  self.model.rotate_unclockwise,
            Qt.Key_Space: self.model.drop_down,
            Qt.Key_D:     self.model.move_down,
        }

    def keyin(self, key):
        if key not in self.key_map:
            return False
        self.key_map[key]()
        return True

    def start(self):
        if not self.is_started:
            self.is_started = True
            self.view.show()
            self.timer.start(self.SPEED)

    def step(self):
        self.model.step()
        if self.model.is_alive():
            self.status.set(f"Score: {self.model.score}")
        else:
            self.status.set(f"Game Over (Score: {self.model.score})")
        self.view.update()

    def pause(self):
        if self.is_started:
            return
        self.is_paused = not self.is_paused
        if self.is_paused:
            self.timer.stop()
            self.status.set("Paused")
        else:
            self.timer.start(self.SPEED)
            self.status.set(f"Score: {self.model.score}")

    def quit(self):
        self.app.quit()


class Status(QObject):
    signal = pyqtSignal(str)

    def on(self, handler):
        self.signal[str].connect(handler)

    def set(self, message):
        # It will call handler(message) that set self.on(handler)
        self.signal.emit(message)


class Timer(QObject):

    def __init__(self, handler):
        super().__init__()
        self.handler = handler
        self.timer = QBasicTimer()

    def start(self, interval):
        # It will call self.timerEvent(event) after each interval
        self.timer.start(interval, self)

    def stop(self):
        self.timer.stop()

    def timerEvent(self, event):
        if event.timerId() == self.timer.timerId():
            self.handler()


def main():
    app = QApplication(sys.argv)
    tetris = Tetris(app).start()
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()
8
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
10