テトリス

Table of Contents

テトリスとは

The tetris game is one of the most popular computer games ever created. The original game was designed and programmed by a russian programmer Alexey Pajitnov in 1985. Since then, tetris is available on almost every computer platform in lots of variations. Even my mobile phone has a modified version of the tetris game.

テトリスはこの世で最も有名なコンピュータゲームのひとつです。 オリジナルは、ロシア人プログラマのアレクセイ・パジトノフによって1985年に開発されました。 それ以来、テトリスはありとあらゆるコンピュータ上で、多くの種類を遊ぶことができます。 私の携帯電話にもテトリスの改良版が入っています。

Tetris is called a falling block puzzle game. In this game, we have seven different shapes called tetrominoes. S-shape, Z-shape, T-shape, L-shape, Line-shape, MirroredL-shape and a Square-shape. Each of these shapes is formed with four squares. The shapes are falling down the board. The object of the tetris game is to move and rotate the shapes, so that they fit as much as possible.

If we manage to form a row, the row is destroyed and we score. We play the tetris game until we top out.

テトリスは落ち物パズルゲームと呼ばれています。 テトリスには、「テトリミノ」と呼ばれる7つの異なる図形が登場します。 S-テトリミノ、Z-テトリミノ、T-テトリミノ、L-テトリミノ、I-テトリミノ、J-テトリミノ、O-テトリミノの7種です。 それぞれのミノは、4つの正方形で構成されます。 ミノはゲーム板の上から下へ落ちていきます。 テトリスの目的は、これらのミノができるだけぴったり合わさるように、 ミノを動かしたり回したりすることです。

行をきっちり埋めると、その行は消滅し得点になります。 一番上の行が詰まるとゲーム終了です。

./img/tetrominoes.png

wxPython is a toolkit designed to create applications. There are other libraries which are targeted at creating computer games. Nevertheless, wxPython and other application toolkits can be used to create games.

wxPythonはアプリケーションを作るために設計されたツールキットです。 コンピュータゲームを作るためのライブラリは他にもあります。 とはいえ、wxPythonやその他のアプリケーション向けツールキットでもゲームを作ることはできます。

開発

We do not have images for our tetris game, we draw the tetrominoes using the drawing API available in the wxPython programming toolkit. Behind every computer game, there is a mathematical model. So it is in tetris.

これから作るテトリスは画像を使いません。 テトリミノはwxPythonの描画用APIを使って表示することにします。

コンピュータゲームの背後には、数学的なモデルがあります。 もちろんテトリスにもです。

Some ideas behind the game.

  • We use wx.Timer to create a game cycle
  • The tetrominoes are drawn
  • The shapes move on a square by square basis (not pixel by pixel)
  • Mathematically a board is a simple list of numbers

テトリスの背後にある考え方。

  • ゲームサイクルを作るために wx.Timer を使う。
  • テトリミノは、画像ではなく、APIを使って描画される。
  • ミノは、ピクセル単位でなく、正方形のブロック単位で移動する。
  • 数学的に、ゲーム板はシンプルな数字のリストとする。

The following example is a modified version of the tetris game, available with PyQt4 installation files.

以下のサンプルは、PyQt4版のテトリスを改造したものです。

#! /usr/bin/env python

# tetris.py

import wx
import random

class Tetris(wx.Frame):
    def __init__(self, parent, id, title):
        wx.Frame.__init__(self, parent, id, title, size=(180, 380))

        self.statusbar = self.CreateStatusBar()
        self.statusbar.SetStatusText('0')
        self.board = Board(self)
        self.board.SetFocus()
        self.board.start()

        self.Centre()
        self.Show(True)

class Board(wx.Panel):
    BoardWidth  = 10
    BoardHeight = 22
    Speed       = 300
    ID_TIMER    = 1

    def __init__(self, parent):
        wx.Panel.__init__(self, parent)

        self.timer              = wx.Timer(self, Board.ID_TIMER)
        self.isWaitingAfterLine = False
        self.curPiece           = Shape()
        self.nextPiece          = Shape()
        self.curX               = 0
        self.curY               = 0
        self.numLinesRemoved    = 0
        self.board              = []

        self.isStarted = False
        self.isPaused  = False

        self.Bind(wx.EVT_PAINT, self.OnPaint)
        self.Bind(wx.EVT_KEY_DOWN, self.OnKeyDown)
        self.Bind(wx.EVT_TIMER, self.OnTimer, id=Board.ID_TIMER)

        self.clearBoard()

    def shapeAt(self, x, y):
        return self.board[(y * Board.BoardWidth) + x]

    def setShapeAt(self, x, y, shape):
        self.board[(y * Board.BoardWidth) + x] = shape

    def squareWidth(self):
        return self.GetClientSize().GetWidth() / Board.BoardWidth

    def squareHeight(self):
        return self.GetClientSize().GetHeight() / Board.BoardHeight

    def start(self):
        if self.isPaused:
            return

        self.isStarted          = True
        self.isWaitingAfterLine = False
        self.numLinesRemoved    = 0
        self.clearBoard()

        self.newPiece()
        self.timer.Start(Board.Speed)

    def pause(self):
        if not self.isStarted:
            return

        self.isPaused = not self.isPaused
        statusbar = self.GetParent().statusbar

        if self.isPaused:
            self.timer.Stop()
            statusbar.SetStatusText('paused')
        else:
            self.timer.Start(Board.Speed)
            statusbar.SetStatusText(str(self.numLinesRemoved))

        self.Reflesh()

    def clearBoard(self):
        for i in range(Board.BoardHeight * Board.BoardWidth):
            self.board.append(Tetrominoes.NoShape)

    def OnPaint(self, event):
        dc = wx.PaintDC(self)

        size = self.GetClientSize()
        boardTop = size.GetHeight() - Board.BoardHeight * self.squareHeight()

        for i in range(Board.BoardHeight):
            for j in range(Board.BoardWidth):
                shape = self.shapeAt(j, Board.BoardHeight - i - 1)
                if shape != Tetrominoes.NoShape:
                    self.drawSquare(dc,
                                    0 + j * self.squareWidth(),
                                    boardTop + i * self.squareHeight(), shape)

        if self.curPiece.shape() != Tetrominoes.NoShape:
            for i in range(4):
                x = self.curX + self.curPiece.x(i)
                y = self.curY - self.curPiece.y(i)
                self.drawSquare(dc, 0 + x * self.squareWidth(),
                                boardTop + (Board.BoardHeight - y - 1) * self.squareHeight(),
                                self.curPiece.shape())

    def OnKeyDown(self, event):
        if not self.isStarted or self.curPiece.shape() == Tetrominoes.NoShape:
            event.Skip()
            return

        keycode = event.GetKeyCode()

        if keycode == ord('P') or keycode == ord('p'):
            self.pause()
            return
        if self.isPaused:
            return
        elif keycode == wx.WXK_LEFT:
            self.tryMove(self.curPiece, self.curX - 1, self.curY)
        elif keycode == wx.WXK_RIGHT:
            self.tryMove(self.curPiece, self.curX + 1, self.curY)
        elif keycode == wx.WXK_DOWN:
            self.tryMove(self.curPiece.rotatedRight(), self.curX, self.curY)
        elif keycode == wx.WXK_UP:
            self.tryMove(self.curPiece.rotatedLeft(), self.curX, self.curY)
        elif keycode == wx.WXK_SPACE:
            self.dropDown()
        elif keycode == ord('D') or keycode == ord('d'):
            self.oneLineDown()
        else:
            event.Skip()

    def OnTimer(self, event):
        if event.GetId() == Board.ID_TIMER:
            if self.isWaitingAfterLine:
                self.isWaitingAfterLine = False
                self.newPiece()
            else:
                self.oneLineDown()
        else:
            event.Skip()

    def dropDown(self):
        newY = self.curY
        while newY > 0:
            if not self.tryMove(self.curPiece, self.curX, newY - 1):
                break
            newY -= 1

        self.pieceDropped()

    def oneLineDown(self):
        if not self.tryMove(self.curPiece, self.curX, self.curY - 1):
            self.pieceDropped()

    def pieceDropped(self):
        for i in range(4):
            x = self.curX + self.curPiece.x(i)
            y = self.curY - self.curPiece.y(i)
            self.setShapeAt(x, y, self.curPiece.shape())

        self.removeFullLines()

        if not self.isWaitingAfterLine:
            self.newPiece()

    def removeFullLines(self):
        numFullLines = 0

        statusbar = self.GetParent().statusbar

        rowsToRemove = []

        for i in range(Board.BoardHeight):
            n = 0
            for j in range(Board.BoardWidth):
                if not self.shapeAt(j, i) == Tetrominoes.NoShape:
                    n = n + 1

            if n == 10:
                rowsToRemove.append(i)

        rowsToRemove.reverse()

        for m in rowsToRemove:
            for k in range(m, Board.BoardHeight):
                for l in range(Board.BoardWidth):
                    self.setShapeAt(l, k, self.shapeAt(l, k+1))

            numFullLines += len(rowsToRemove)

            if numFullLines > 0:
                self.numLinesRemoved += numFullLines
                statusbar.SetStatusText(str(self.numLinesRemoved))
                self.isWaitingAfterLine = True
                self.curPiece.setShape(Tetrominoes.NoShape)
                self.Refresh()

    def newPiece(self):
        self.curPiece = self.nextPiece
        statusbar = self.GetParent().statusbar
        self.nextPiece.setRandomShape()
        self.curX = Board.BoardWidth / 2 + 1
        self.curY = Board.BoardHeight - 1 + self.curPiece.minY()

        if not self.tryMove(self.curPiece, self.curX, self.curY):
            self.curPiece.setShape(Tetrominoes.NoShape)
            self.timer.Stop()
            self.isStarted = False
            statusbar.SetStatusText('Game over')

    def tryMove(self, newPiece, newX, newY):
        for i in range(4):
            x = newX + newPiece.x(i)
            y = newY - newPiece.y(i)
            if x < 0 or x >= Board.BoardWidth or y < 0 or y >= Board.BoardHeight:
                return False
            if self.shapeAt(x, y) != Tetrominoes.NoShape:
                return False

        self.curPiece = newPiece
        self.curX = newX
        self.curY = newY
        self.Refresh()
        return True

    def drawSquare(self, dc, x, y, shape):
        colors = ['#000000', '#cc6666', '#66cc66', '#6666cc',
                  '#cccc66', '#cc66cc', '#66cccc', '#daaa00']

        light  = ['#000000', '#f89fab', '#79fc79', '#7979fc',
                  '#fcfc79', '#fc79fc', '#79fcfc', '#fcc600']

        dark   = ['#000000', '#803c3b', '#3b803b', '#3b3b80',
                  '#80803b', '#803b80', '#3b8080', '#806200']

        pen = wx.Pen(light[shape])
        pen.SetCap(wx.CAP_PROJECTING)
        dc.SetPen(pen)

        dc.DrawLine(x, y + self.squareHeight() - 1, x, y)
        dc.DrawLine(x, y, x + self.squareWidth() - 1, y)

        darkPen = wx.Pen(dark[shape])
        darkPen.SetCap(wx.CAP_PROJECTING)
        dc.SetPen(darkPen)

        dc.DrawLine(x + 1, y + self.squareHeight() - 1,
                    x + self.squareWidth() - 1, y + self.squareHeight() - 1)
        dc.DrawLine(x + self.squareWidth() - 1, y + self.squareHeight() -1,
                    x + self.squareWidth() - 1, y + 1)

        dc.SetPen(wx.TRANSPARENT_PEN)
        dc.SetBrush(wx.Brush(colors[shape]))
        dc.DrawRectangle(x + 1, y + 1, self.squareWidth() - 2,
                         self.squareHeight() - 2)


class Tetrominoes(object):
    NoShape        = 0
    ZShape         = 1
    SShape         = 2
    LineShape      = 3
    TShape         = 4
    SquareShape    = 5
    LShape         = 6
    MirroredLShape = 7

class Shape(object):
    coordTable = (
        ((0, 0), (0, 0), (0, 0), (0, 0)),
        ((0, -1), (0, 0), (-1, 0), (-1, 1)),
        ((0, -1), (0, 0), (1, 0), (1, 1)),
        ((0, -1), (0, 0), (0, 1), (0, 2)),
        ((-1, 0), (0, 0), (1, 0), (0, 1)),

        ((0, 0), (1, 0), (0, 1), (1, 1)),
        ((-1, -1), (0, -1), (0, 0), (0, 1)),
        ((1, -1), (0, -1), (0, 0), (0, 1)))

    def __init__(self):
        self.coords = [[0, 0] for i in range(4)]
        self.pieceShape = Tetrominoes.NoShape

        self.setShape(Tetrominoes.NoShape)

    def shape(self):
        return self.pieceShape

    def setShape(self, shape):
        table = Shape.coordTable[shape]
        for i in range(4):
            for j in range(2):
                self.coords[i][j] = table[i][j]

        self.pieceShape = shape

    def setRandomShape(self):
        self.setShape(random.randint(1, 7))

    def x(self, index):
        return self.coords[index][0]

    def y(self, index):
        return self.coords[index][1]

    def setX(self, index, x):
        self.coords[index][0] = x

    def setY(self, index, y):
        self.coords[index][1] = y

    def minX(self):
        m = self.coord[0][0]
        for i in range(4):
            m = min(m, self.coords[i][0])

        return m

    def maxX(self):
        m = self.coord[0][0]
        for i in range(4):
            m = max(m, self.coords[i][0])

        return m

    def minY(self):
        m = self.coords[0][1]
        for i in range(4):
            m = min(m, self.coords[i][1])

        return m

    def maxY(self):
        m = self.coords[0][1]
        for i in range(4):
            m = max(m, self.coords[i][1])

        return m

    def rotatedLeft(self):
        if self.pieceShape == Tetrominoes.SquareShape:
            return self

        result = Shape()
        result.pieceShape = self.pieceShape
        for i in range(4):
            result.setX(i, self.y(i))
            result.setY(i, -self.x(i))

        return result

    def rotatedRight(self):
        if self.pieceShape == Tetrominoes.SquareShape:
            return self

        result = Shape()
        result.pieceShape = self.pieceShape
        for i in range(4):
            result.setX(i, -self.y(i))
            result.setY(i, self.x(i))

        return result

app = wx.App()
Tetris(None, -1, 'tetris.py')
app.MainLoop()

I have simplified the game a bit, so that it is easier to understand. The game starts immediately, after it is launched. We can pause the game by pressing the p key. The space key will drop the tetris piece immediately to the bottom. The d key will drop the piece one line down. (It can be used to speed up the falling a bit.) The game goes at constant speed, no acceleration is implemented. The score is the number of lines, that we have removed.

ゲームは少し簡略化してあるので理解しやすいと思います。 アプリケーションを立ち上げるとすぐにゲームが開始されます。 pキーを押すと、ゲームを中断することができます。 スペースキーでテトリミノを即座に落下させることができます。 dキーはミノを1行ずつ落下させます。(落下速度を少し速くしたい場合に使いましょう) ゲーム速度は一定で、速くなることはありません。 スコアは消去した行数です。

...
self.curX = 0
self.curY = 0
self.numLinesRemoved = 0
self.board = []
...

Before we start the game cycle, we initialize some important variables. The self.board variable is a list of numbers from 0 … 7. It represents the position of various shapes and remains of the shapes on the board.

ゲームループを開始する前に、重要な変数をいくつか初期化します。 self.board 変数は0〜7の数のリストです。 これは、ゲーム盤上のミノとその残骸の位置を示します。

for i in range(Board.BoardHeight):
    for j in range(Board.BoardWidth):
        shape = self.shapeAt(j, Board.BoardHeight - i - 1)
        if shape != Tetrominoes.NoShape:
            self.drawSquare(dc,
                0 + j * self.squareWidth(),
                boardTop + i * self.squareHeight(), shape)

The painting of the game is divided into two steps. In the first step, we draw all the shapes, or remains of the shapes, that have been dropped to the bottom of the board. All the squares are rememberd in the self.board list variable. We access it using the shapeAt() method.

ゲームの描画は2段階に分けられます。 第1段階では、ゲーム盤の底に落下した全てのミノとその残骸を描画します。 全ての正方形が self.board リスト変数に記録されます。 shapeAt() メソッドを使うことで、この変数にアクセスできます。

if self.curPiece.shape() != Tetrominoes.NoShape:
    for i in range(4):
        x = self.curX + self.curPiece.x(i)
        y = self.curY - self.curPiece.y(i)
        self.drawSquare(dc, 0 + x * self.squareWidth(),
            boardTop + (Board.BoardHeight - y - 1) * self.squareHeight(),
            self.curPiece.shape())

The next step is drawing of the actual piece, that is falling down.

次の段階では現在落下中のミノを描画します。

elif keycode == wx.WXK_LEFT:
    self.tryMove(self.curPiece, self.curX - 1, self.curY)

In the OnKeyDown() method we check for pressed keys. If we press the left arrow key, we try to move the piece to the left. We say try, because the piece might not be able to move.

OnKeyDown() メソッドで、押下されたキーを調べます。 左矢印キーを押すと、ミノを左に 動かそうと します。 なぜ「動かそうとする」なのかというと、ミノは移動できないかもしれないからです。

def tryMove(self, newPiece, newX, newY):
    for i in range(4):
        x = newX + newPiece.x(i)
        y = newY - newPiece.y(i)
        if x < 0 or x >= Board.BoardWidth or y < 0 or y >= Board.BoardHeight:
            return False
        if self.shapeAt(x, y) != Tetrominoes.NoShape:
            return False

    self.curPiece = newPiece
    self.curX = newX
    self.curY = newY
    self.Refresh()
    return True

In the tryMove() method we try to move our shapes. If the shape is at the edge of the board or is adjacent to some other piece, we return false. Otherwise we place the current falling piece to a new position and return true.

tryMove() メソッドで、ミノを動かそうとします。 もしミノがゲーム盤の端だったり、他のミノに隣接していたりすると False を返します。 そうでなければ、現在落下中のミノを新しい位置に置き、 True を返します。

def OnTimer(self, event):
    if event.GetId() == Board.ID_TIMER:
        if self.isWaitingAfterLine:
            self.isWaitingAfterLine = False
            self.newPiece()
        else:
            self.oneLineDown()
    else:
        event.Skip()

In the OnTimer() method we either create a new piece, after the previous one was dropped to the bottom, or we move a falling piece one line down.

OnTimer() メソッドでは、以前のミノが底に落下しきった後に新しいミノを生成したり、落下中のミノを1行落としたりします。

def removeFullLines(self):
    numFullLines = 0

    rowsToRemove = []

    for i in range(Board.BoardHeight):
        n = 0
        for j in range(Board.BoardWidth):
            if not self.shapeAt(j, i) == Tetrominoes.NoShape:
                n = n + 1

        if n == 10:
            rowsToRemove.append(i)

     rowsToRemove.reverse()

     for m in rowsToRemove:
         for k in range(m, Board.BoardHeight):
             for l in range(Board.BoardWidth):
                 self.setShapeAt(l, k, self.shapeAt(l, k + 1))
...

If the piece hits the bottom, we call the removeFullLines() method. First we find out all full lines. And we remove them. We do it by moving all lines above the current full line to be removed one line down. Notice, that we reverse the order of the lines to be removed. Otherwise, it would not work correctly. In our case we use a naive gravity. This means, that the pieces may be floating above empty gaps.

ミノが底に当たると、 removeFullLines() メソッドを呼びます。 揃った行を見つけて、その行を削除します。 現在揃っている行を取り除き、その上の行全てを1行下げることでこれを実現します。 削除される行の順番を逆にしていることに注意してください。 そうでもしなければ、正しく動かないでしょうからね。 今回は単純な重力を使用しています。 つまり、ミノは空の隙間の上に浮かぶこともありうるということです。

def newPiece(self):
    self.curPiece = self.nextPiece
    statusbar = self.GetParent().statusbar
    self.nextPiece.setRandomShape()
    self.curX = Board.BoardWidth / 2 + 1
    self.curY = Board.BoardHeight - 1 + self.curPiece.minY()

    if not self.tryMove(self.curPiece, self.curX, self.curY):
        self.curPiece.setShape(Tetrominoes.NoShape)
        self.timer.Stop()
        self.isStarted = False
        statusbar.SetStatusText('Game over')

The newPiece() method creates randomly a new tetris piece. If the piece cannot go into it's initial position, the game is over.

newPiece() メソッドはランダムに新しいミノを生成します。 ミノを初期位置に配置できなければ、ゲームオーバーです。

The Shape class saves information about the tetris piece.

Shape クラスはテトリミノの情報を保持しています。

self.coords = [[0,0] for i in range(4)]

Upon creation we create an empty coordinates list. The list will save the coordinates of the tetris piece. For example, these tuples (0, -1), (0, 0), (1, 0), (1, 1) represent a rotated S-shape. The following diagram illustrates the shape.

ミノの作成中に、 空の座標リストを作成します。 このリストは、テトリミノの座標を保持します。 例えば、(0, -1), (0, 0), (1, 0), (1, 1) のタプルは回転したS-テトリミノを表します。 以下の図表がミノを図示しています。

./img/coordinates.png

When we draw the current falling piece, we draw it at self.curX, self.curY position. Then we look at the coordinates table and draw all the four squares.

現在落下中のミノを描画するときは、ミノを self.curXself.curY の位置に描画します。 そして、座標テーブルを参照し、4つの正方形全てを描画します。

./img/tetris.png

The original page is here.

Date: 2010-11-17 16:56:35 UTC

HTML generated by org-mode 7.3 in emacs 23