Edited at

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

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()