ZetCodeのPyQt5チュートリアルにあるテトリスのソースコードを拝見したのですが、モデルが分離されておらず、気になってしまったので 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()