Tetromino (Python, Albert Sweigart, 2012)

来自俄罗斯方块中文维基
Tetromino
开发 Albert Sweigart
游戏平台 Python(Pygame)
发行时间 2012年
游戏信息
预览块数 1
场地大小 10 × 20
暂存块
硬降(但有瞬降
旋转系统 专用
Tetromino (Python, Albert Sweigart, 2012) title.png
Tetromino (Python, Albert Sweigart, 2012) ingame.png

Tetromino 是一款 Python 四连方块游戏。
该游戏是 Making Games with Python & Pygame 一书第七章的内容。
该游戏的代码和编程讲解为许多其他的 Python 四连方块游戏提供了支持。

玩法

升级、得分。
开局 1 级,每消 10 行升级,得分等于总消行数。
方块自动下落间隔 = (0.27 - 等级 × 0.02) 秒。
方块堆超出第 20 行的部分消失。
死亡判定:重叠死亡

操作

A/D 或左右键横移,Q 逆时针旋转,W 或上键顺时针旋转,S 或下键软降,空格键瞬降。
横移和软降可以叠加长按,旋转和瞬降只能单点但可插入长按。
单纯长按可跨块,被插入其他操作后不可跨块。

方块环境配置

七种方块都是纯色,在红黄蓝绿四种颜色当中随机取一种。

Tet.pngTet.pngTet.png4444Tet.pngTet.pngTet.png
Tet.pngTet.pngTet.png43X3Tet.pngTet.pngTet.png
Tet.pngTet.pngTet.png4223Tet.pngTet.pngTet.png
Tet.pngTet.pngTet.png4223Tet.pngTet.pngTet.png

碰撞箱的入场位置如上图所示。
其中,X 格坐标为 (6,21)。
方块入场时朝向随机。
以下图组中,每一组的最左图取横向入场朝向,右图是依次顺时针旋转的朝向:

ZZ
ZZ
Tet.pngTet.pngTet.pngTet.png
Tet.pngTet.pngTet.pngTet.png
OOOO
Tet.pngTet.pngTet.pngTet.png
Tet.pngTet.pngOTet.png
Tet.pngTet.pngOTet.png
Tet.pngTet.pngOTet.png
Tet.pngTet.pngOTet.png
JTet.pngTet.png
JJJ
Tet.pngTet.pngTet.png
Tet.pngJJ
Tet.pngJTet.png
Tet.pngJTet.png
Tet.pngTet.pngTet.png
JJJ
Tet.pngTet.pngJ
Tet.pngJTet.png
Tet.pngJTet.png
JJTet.png
Tet.pngTet.pngS
SSS
Tet.pngTet.pngTet.png
Tet.pngSTet.png
Tet.pngSTet.png
Tet.pngSS
Tet.pngTet.pngTet.png
SSS
STet.pngTet.png
SSTet.png
Tet.pngSTet.png
Tet.pngSTet.png
Tet.pngZTet.png
ZZZ
Tet.pngTet.pngTet.png
Tet.pngZTet.png
Tet.pngZZ
Tet.pngZTet.png
Tet.pngTet.pngTet.png
ZZZ
Tet.pngZTet.png
Tet.pngZTet.png
ZZTet.png
Tet.pngZTet.png
Tet.pngTet.pngTet.png
Tet.pngOO
OOTet.png
Tet.pngOTet.png
Tet.pngOO
Tet.pngTet.pngO
Tet.pngTet.pngTet.png
JJTet.png
Tet.pngJJ
Tet.pngJTet.png
JJTet.png
JTet.pngTet.png

Tetromino (Python, Albert Sweigart, 2012) 没有踢墙。

第七章的讲解

此处围绕原文调整了部分内容。

  第一部分:游戏概念和源代码  
游戏方法和六个术语
Tetromino 是“俄罗斯方块”的一个克隆版本。不同形状的四连方块从屏幕顶上落下,玩家要尽量控制它们填满整行消除(更上方的行随之下落),直到方块堆填满场地、新方块入场空间不足。
  1. 场地(Board):方块的活动范围(宽 10 格,高 20 格)
    CHAPTER 7 - TETROMINO image002.png
  2. 格子(Box):场地中被填的一格
  3. 方块(Piece):由四个格子组成的整体,从场地顶上落下,可被移动和旋转
  4. 形状(Shape):T、S、Z、J、L、I、O
  5. 模板(Template):表示全部旋转状态的形状数据(使用如 S_SHAPE_TEMPLATE 的变量名称)
  6. 着陆(Landed):方块底边贴到场底或其他格子(下一块应开始落下)

Tetromino 的源代码
获取地址:本条目第一个外链的原文正文的第一个链接。

  第二部分:初始设置代码  
通常初始设置
import random, time, pygame, sys
from pygame.locals import *

FPS = 25
WINDOWWIDTH = 640
WINDOWHEIGHT = 480
BOXSIZE = 20
BOARDWIDTH = 10
BOARDHEIGHT = 20
BLANK = '.'

首先,布置方块游戏的常量。
导入需要用到的四个模块,设置游戏帧率和场地数据结构。

长按操作时间常量初始设置

MOVESIDEWAYSFREQ = 0.15
MOVEDOWNFREQ = 0.1

横移 ARR = 0.15 秒,软降 ARR = 0.1 秒。

窗口布局初始设置

XMARGIN = int((WINDOWWIDTH - BOARDWIDTH * BOXSIZE) / 2)
TOPMARGIN = WINDOWHEIGHT - (BOARDHEIGHT * BOXSIZE) - 5
CHAPTER 7 - TETROMINO image003.png

游戏场地靠下居中。
侧边距(XMARGIN)等于窗宽(WINDOWWIDTH)减实际场宽(BOARDWIDTH * BOXSIZE)[注 1]结果的一半,int 取整。
顶边距(TOPMARGIN)本是等于窗高(WINDOWHEIGHT)减实际场高,此处额外减去 5 像素,分给底边距。

颜色初始设置

#               R    G    B
WHITE       = (255, 255, 255)
GRAY        = (185, 185, 185)
BLACK       = (  0,   0,   0)
RED         = (155,   0,   0)
LIGHTRED    = (175,  20,  20)
GREEN       = (  0, 155,   0)
LIGHTGREEN  = ( 20, 175,  20)
BLUE        = (  0,   0, 155)
LIGHTBLUE   = ( 20,  20, 175)
YELLOW      = (155, 155,   0)
LIGHTYELLOW = (175, 175,  20)

BORDERCOLOR = BLUE
BGCOLOR = BLACK
TEXTCOLOR = WHITE
TEXTSHADOWCOLOR = GRAY
COLORS      = (     BLUE,      GREEN,      RED,      YELLOW)
LIGHTCOLORS = (LIGHTBLUE, LIGHTGREEN, LIGHTRED, LIGHTYELLOW)
assert len(COLORS) == len(LIGHTCOLORS) # 断言深色数等于浅色数

为方块安排红黄蓝绿这四种颜色。
每一种颜色要有一深一浅,以便方块可被做出阴影效果。
四种深色和四种浅色的数据分别储存在 COLORS 和 LIGHTCOLORS 这两个元组(tuple)当中。
在 Python 中,元组的内容括在小括号里。
此处使用了 Python 的“断言”(assert)语句,保证深浅色数异常时程序终止。

  第三部分:方块模板  
TEMPLATEWIDTH = 5
TEMPLATEHEIGHT = 5

S_SHAPE_TEMPLATE = [['.....',
                     '.....',
                     '..OO.',
                     '.OO..',
                     '.....'],
                    ['.....',
                     '..O..',
                     '..OO.',
                     '...O.',
                     '.....']]

Z_SHAPE_TEMPLATE = [['.....',
                     '.....',
                     '.OO..',
                     '..OO.',
                     '.....'],
                    ['.....',
                     '..O..',
                     '.OO..',
                     '.O...',
                     '.....']]

I_SHAPE_TEMPLATE = [['..O..',
                     '..O..',
                     '..O..',
                     '..O..',
                     '.....'],
                    ['.....',
                     '.....',
                     'OOOO.',
                     '.....',
                     '.....']]

O_SHAPE_TEMPLATE = [['.....',
                     '.....',
                     '.OO..',
                     '.OO..',
                     '.....']]

J_SHAPE_TEMPLATE = [['.....',
                     '.O...',
                     '.OOO.',
                     '.....',
                     '.....'],
                    ['.....',
                     '..OO.',
                     '..O..',
                     '..O..',
                     '.....'],
                    ['.....',
                     '.....',
                     '.OOO.',
                     '...O.',
                     '.....'],
                    ['.....',
                     '..O..',
                     '..O..',
                     '.OO..',
                     '.....']]

L_SHAPE_TEMPLATE = [['.....',
                     '...O.',
                     '.OOO.',
                     '.....',
                     '.....'],
                    ['.....',
                     '..O..',
                     '..O..',
                     '..OO.',
                     '.....'],
                    ['.....',
                     '.....',
                     '.OOO.',
                     '.O...',
                     '.....'],
                    ['.....',
                     '.OO..',
                     '..O..',
                     '..O..',
                     '.....']]

T_SHAPE_TEMPLATE = [['.....',
                     '..O..',
                     '.OOO.',
                     '.....',
                     '.....'],
                    ['.....',
                     '..O..',
                     '..OO.',
                     '..O..',
                     '.....'],
                    ['.....',
                     '.....',
                     '.OOO.',
                     '..O..',
                     '.....'],
                    ['.....',
                     '..O..',
                     '.OO..',
                     '..O..',
                     '.....']]

这一步是在定义方块的碰撞箱
七种方块的形状和旋转状态用字符串数据的列表(list)表示。
一个列表是一个旋转状态,包含了几个列表的列表是一个方块的所有旋转状态。
在 Python 中,字符串括在单引号内,列表括在方括号内。
Python 列表的结束位置以右方括号为准,中间的字符串可以适当换行以提高代码的可辨性。
方块的格子用实体字符表示,此处选用 O,继续照顾代码的可辨性;
空格已在前面的“通常初始设置”定义(BLANK = '.')。
在代码层面,此处取的是 5×5 碰撞箱,它能向下兼容实际游戏中的 3×3 和 4×4。

SHAPES = {'S': S_SHAPE_TEMPLATE,
          'Z': Z_SHAPE_TEMPLATE,
          'J': J_SHAPE_TEMPLATE,
          'L': L_SHAPE_TEMPLATE,
          'I': I_SHAPE_TEMPLATE,
          'O': O_SHAPE_TEMPLATE,
          'T': T_SHAPE_TEMPLATE}
 

游戏中有七种方块,每一种方块的模板又常有多个列表。
作为它们的整体概念,“形状”(SHAPES)适合用 Python 的“字典”(dict)数据类型表示。
字典数据由键对值组组成,冒号分开键和值,逗号隔开组,整体括在花括号内。

  第四部分:定义 main()  
def main():
    global FPSCLOCK, DISPLAYSURF, BASICFONT, BIGFONT
    pygame.init()
    FPSCLOCK = pygame.time.Clock()
    DISPLAYSURF = pygame.display.set_mode((WINDOWWIDTH, WINDOWHEIGHT))
    BASICFONT = pygame.font.Font('freesansbold.ttf', 18)
    BIGFONT = pygame.font.Font('freesansbold.ttf', 100)
    pygame.display.set_caption('Tetromino')

    showTextScreen('Tetromino')

这一步是在定义每秒帧数计时器、标题画面和游戏字体。
游戏代码开头已经导入了 pygame 模块,此处用 pygame.init() 完成初始化,以便直接调用其中的内容。
此处的 main() 并非程序的执行入口,其重点是前面的 def,它是在定义一些全局常量。[注 2]

    while True: # 游戏循环
        if random.randint(0, 1) == 0:
            pygame.mixer.music.load('tetrisb.mid')
        else:
            pygame.mixer.music.load('tetrisc.mid')
        pygame.mixer.music.play(-1, 0.0)
        runGame()
        pygame.mixer.music.stop()
        showTextScreen('Game Over')

这一步是先背景音乐随机二选一,再用 runGame() 函数执行实际游戏的全部代码。
如果一局游戏输掉了,runGame() 将会转回 main(),停掉背景音乐,显示游戏结束画面。
此后按任意键,showTextScreen() 函数转回 “if random.randint(0, 1) == 0:” 开始新游戏,实现游戏循环。
其具体的转回代码需要在 runGame() 部分写完后再定义,此处只是先布置好函数。
至此,方块游戏的起始和结束已经搭好了架子,接下来可以开始编写游戏的核心玩法。

  第五部分:定义 runGame()  

开局的变量

def runGame():
    # 设置游戏开局的初始变量
    board = getBlankBoard()
    lastMoveDownTime = time.time()
    lastMoveSidewaysTime = time.time()
    lastFallTime = time.time()
    movingDown = False # 四连方块游戏不设置方块向上移动的变量
    movingLeft = False
    movingRight = False
    score = 0
    level, fallFreq = calculateLevelAndFallFreq(score)

    fallingPiece = getNewPiece()
    nextPiece = getNewPiece()

方块的三向移动情况可用布尔值表示,ARR 情况可用 pygame 的时间控制模块表示,开局分数可用数值“0”表示。
空白场地、升级、重力、当前方块和预览块则不能由数值或布尔值直接表示,要布置新函数,之后也要补充定义。

方块的循环

    while True: # 主循环
        if fallingPiece == None:
            # 可控制的方块没有了,在顶上生成一个新的
            fallingPiece = nextPiece
            nextPiece = getNewPiece()
            lastFallTime = time.time() # 重置下落间隔计时器

            if not isValidPosition(board, fallingPiece):
                return # 新方块入场空间不足,则游戏结束

        checkForQuit()

下落式方块游戏的主循环是“入场 - 下落 - 着陆 - 再入场”。
与之相配的出块逻辑是“方块着陆后数据变为‘无’(None) - 把预览块的变量数据复制过来 - 随机生成一个新形状补充预览块”。
逻辑三引出函数 getNewPiece(),检查方块入场重叠死亡的需求引出函数 isValidPosition(board, fallingPiece),都要在后面补写。
如果死亡成立,游戏主进程函数 runGame() 就返回。
特别地,此处重置下落间隔的计时器,使新方块入场后的第一落总能有一个标准间隔。

操作的循环 - 松键

        for event in pygame.event.get(): # 处理操作的循环
            if event.type == KEYUP:

对于一个用键盘操作的游戏,玩家的操作分按键和松键两种。[注 3]
pygame 的 event 模块将这两种操作视作“不同类型(type)的事件(event)”,可以分开讨论。
此处先讨论松键,它有以下四种情况:

                if (event.key == K_p):
                    # 暂停游戏
                    DISPLAYSURF.fill(BGCOLOR)
                    pygame.mixer.music.stop()
                    showTextScreen('Paused') # 保持暂停,直到有新按键
                    pygame.mixer.music.play(-1, 0.0)
                    lastFallTime = time.time()
                    lastMoveDownTime = time.time()
                    lastMoveSidewaysTime = time.time()

先是暂停。
此处讨论松键类型,所以游戏中真正使暂停生效的操作是“松开 P 键”而不是“按下 P 键”。
从情理上说,如果下落式方块游戏有暂停的功能,场地应在暂停期间不可视,否则玩家可以通过滥用暂停来任意延长思考时间。
“DISPLAYSURF.fill(BGCOLOR)” 是显示一个黑屏,完全遮住场地。
此处可以继续使用前面用过的 showTextScreen() 函数实现按键返回。
为使重开时的节奏正常,解除暂停后要恢复自动下落间隔和 ARR 的计时器。

                elif (event.key == K_LEFT or event.key == K_a):
                    movingLeft = False
                elif (event.key == K_RIGHT or event.key == K_d):
                    movingRight = False
                elif (event.key == K_DOWN or event.key == K_s):
                    movingDown = False

再是方块的三向移动。
松开某个方向的键,方块在那个方向上的移动就成为“假”。
Tetromino 安排了两套按键,所以等效的两个按键都要包括在内。

操作的循环 - 按键

            elif event.type == KEYDOWN:
                # 横移方块
                if (event.key == K_LEFT or event.key == K_a) and isValidPosition(board, fallingPiece, adjX=-1):
                    fallingPiece['x'] -= 1
                    movingLeft = True
                    movingRight = False
                    lastMoveSidewaysTime = time.time()

左移要方块左侧邻格全是空格。有任意的格子或墙壁阻挡,左移就不成立。
如果按下左键,它不仅左移成真,还会右移成假。于是,长按右移之后,插入左移操作,右移就被打断。
左移成立时,方块 x 值 -=1。虽然它只动一个值,但这个值是列表数据类型的一部分,所以仍要用方括号。
lastMoveSidewaysTime = time.time() 函数用来实现 DAS 和 ARR。
右移代码略,它是同理的,方块 x 值 +=1,横坐标调整值(adjX)=1,左右真假对调。

旋转方块

                # 旋转方块(如果空间足够的话)
                elif (event.key == K_UP or event.key == K_w):
                    fallingPiece['rotation'] = (fallingPiece['rotation'] + 1) % len(SHAPES[fallingPiece['shape']])
                    if not isValidPosition(board, fallingPiece):
                        fallingPiece['rotation'] = (fallingPiece['rotation'] - 1) % len(SHAPES[fallingPiece['shape']])
                elif (event.key == K_q): # 另一个方向的旋转
                    fallingPiece['rotation'] = (fallingPiece['rotation'] - 1) % len(SHAPES[fallingPiece['shape']])
                    if not isValidPosition(board, fallingPiece):
                        fallingPiece['rotation'] = (fallingPiece['rotation'] + 1) % len(SHAPES[fallingPiece['shape']])

从人类认知方向顺序的习惯上说,如果方块游戏支持双向旋转,旋转状态序号 +1 对应顺时针旋转,-1 逆时针。
在代码层面,它是要正在下落的方块(fallingPiece)的旋转(rotation)键值 +1 或 -1。
但是方块的旋转状态总数有限,突破上下限时都要返回,这一点可通过模运算(符号为 %)完美解决。[注 4]
如果方块转不动,就取相反数调回键值,使方块的旋转状态序号保持不变。

软降方块

                # 用下键使方块更快地降落
                elif (event.key == K_DOWN or event.key == K_s):
                    movingDown = True
                    if isValidPosition(board, fallingPiece, adjY=1):
                        fallingPiece['y'] += 1
                    lastMoveDownTime = time.time()

和横移同理,方块 y 值 +=1,lastMoveDownTime = time.time() 函数用来实现软降 ARR。

瞬降方块(寻底)

                # 使当前方块一路降到底
                elif event.key == K_SPACE:
                    movingDown = False
                    movingLeft = False
                    movingRight = False
                    for i in range(1, BOARDHEIGHT):
                        if not isValidPosition(board, fallingPiece, adjY=i):
                            break
                    fallingPiece['y'] += i - 1

从代码上说,“瞬降”是方块纵坐标突变的现象。
它不但要没有中间状态,还要和其他移动系操作不发生任何叠加,所以三向移动都要先成“假”。
瞬降所要增加的方块纵坐标取决于着陆之前最多能降几格,问题就转变成在几个关键列里寻找方块堆最高的那一格。
设所需的纵坐标调整值(adjY)为 i,只要逐渐 +1 直到检测出非空格[注 5],就能确定 i。
方块是要瞬降在这一格的上方一行,故 y 值增加 (i - 1)。

按键移动

        # 处理用户输入按键所引起的方块移动
        if (movingLeft or movingRight) and time.time() - lastMoveSidewaysTime > MOVESIDEWAYSFREQ:
            if movingLeft and isValidPosition(board, fallingPiece, adjX=-1):
                fallingPiece['x'] -= 1
            elif movingRight and isValidPosition(board, fallingPiece, adjX=1):
                fallingPiece['x'] += 1
            lastMoveSidewaysTime = time.time()

        if movingDown and time.time() - lastMoveDownTime > MOVEDOWNFREQ and isValidPosition(board, fallingPiece, adjY=1):
            fallingPiece['y'] += 1
            lastMoveDownTime = time.time()

按下方向键时,除了方块坐标改变,游戏程序还要捕获按键瞬间的时刻信息,以便做出 DAS 的效果。
移动系操作长按成立有三个条件:正在输入方向,当前时刻减捕获时刻的差值超过了 DAS 的预设值,方块相应一侧全是空格。
DAS 启动成功后,ARR 所要参照的“捕获时刻”要相应地刷新,这样才能连续地检测出差值符合移动条件,使方块连续移动。

重力(自然下落)

        # 每隔一定时间,方块自动下落一格
        if time.time() - lastFallTime > fallFreq:
            # 检查方块是否着陆
            if not isValidPosition(board, fallingPiece, adjY=1):
                # 方块已经着陆,就固定在场上
                addToBoard(board, fallingPiece)
                score += removeCompleteLines(board)
                level, fallFreq = calculateLevelAndFallFreq(score)
                fallingPiece = None
            else:
                # 方块尚未着陆,就下落一格
                fallingPiece['y'] += 1
                lastFallTime = time.time()

重力本身会变化,由 lastFallTime 这个变量控制。
检测着陆的方法是试将方块 y 值 +1,会被挡就着陆,不会被挡就下落一格。
着陆成功的方块要固定成为方块堆的一部分变入场地数据结构,由 addToBoard() 函数完成。
这个动作可能会引发消行并得分,由 removeCompleteLines() 函数处理。
得分可能会引发升级,重力要随之增大,由 calculateLevelAndFallFreq() 函数处理。
最后,当前方块的变量(fallingPiece)要设为“无”,使得预览块的数据可以复制过来。

画面实显

        # 将这一切显示在画面上
        DISPLAYSURF.fill(BGCOLOR)
        drawBoard(board)
        drawStatus(score, level)
        drawNextPiece(nextPiece)
        if fallingPiece != None:
            drawPiece(fallingPiece)

        pygame.display.update()
        FPSCLOCK.tick(FPS)

画面实显是由函数完成的,按不同部分的显示内容加以分工。
pygame.display.update() 实现最终的画面显示,tick() 为全局添加轻微的暂停以保证游戏不会运行得过快。
至此,runGame() 定义完毕,接下来要为前面安排好的各个函数补充定义。

  第六部分:定义其他函数  

制作文本对象

def makeTextObjs(text, font, color):
    surf = font.render(text, True, color)
    return surf, surf.get_rect()

这个函数能方便文字和图像的显示。
对指定的文本、字体对象、颜色对象,它调用 render() 函数,返回 Surface 和 Rect 对象。
Rect 是用于存储矩形坐标的 Pygame 对象,它能将文字显示在屏幕的指定位置;
Surface 是存储由字体渲染而成的图像的 Pygame 对象,它能把方块游戏的各种概念落实到图形上面。
有了这个函数,就不需要每次都用很长的代码来创建 Surface 和 Rect 对象了。

终止程序

def terminate():
    pygame.quit()
    sys.exit()

使玩家有条件手动(例如松开 Esc 键的时候)终止程序。

检测玩家的操作

def checkForKeyPress():
    # 遍历事件序列,寻找松键事件
    # 抓取松键事件,将其移出事件序列
    checkForQuit()

    for event in pygame.event.get([KEYDOWN, KEYUP]):
        if event.type == KEYDOWN:
            continue
        return event.key
    return None

如前文所述,操作分松键和按键两种。
首先要注意到,这个游戏有终止程序的操作,如果终止成立,这一步检测就无法执行了。
因此,先要检查操作是否会终止程序,此处由 checkForQuit() 完成。
如果程序不终止,就忽略松键事件、返回按键事件。
如果这两种事件都不存在,就返回空值。

显示文本

def showTextScreen(text):
    # 此函数在屏幕正中显示大字,一旦玩家按下任意键,就进入游戏
    # 绘制文本阴影
    titleSurf, titleRect = makeTextObjs(text, BIGFONT, TEXTSHADOWCOLOR)
    titleRect.center = (int(WINDOWWIDTH / 2), int(WINDOWHEIGHT / 2))
    DISPLAYSURF.blit(titleSurf, titleRect)

    # 绘制文本
    titleSurf, titleRect = makeTextObjs(text, BIGFONT, TEXTCOLOR)
    titleRect.center = (int(WINDOWWIDTH / 2) - 3, int(WINDOWHEIGHT / 2) - 3)
    DISPLAYSURF.blit(titleSurf, titleRect)

    # 绘制提示文本
    pressKeySurf, pressKeyRect = makeTextObjs('Press a key to play.', BASICFONT, TEXTCOLOR)
    pressKeyRect.center = (int(WINDOWWIDTH / 2), int(WINDOWHEIGHT / 2) + 100)
    DISPLAYSURF.blit(pressKeySurf, pressKeyRect)

    while checkForKeyPress() == None:
        pygame.display.update()
        FPSCLOCK.tick()

游戏的前中后三个阶段都涉及到文本显示,它们内容不同、性质相同,只需 showTextScreen() 一个函数即可完成。
作者希望把三处大字(标题画面游戏名称“Tetromino”、暂停画面“Pause”、游戏结束“Game Over”)做出上明下暗的文本阴影效果。
实现方法:取半场宽、半场高的坐标位置,把灰色暗字[注 6]的 Rect 对象置于窗口正中,白色明字在此基础上向左上偏 3 像素。
提示文本同理但不使用阴影效果,横向位置居中,纵向位置处于大字下方 100 像素。
要使文本停留,就像最后这段代码这样,只要检测不到操作[注 7],就每次都在屏幕上刷新相同的文本内容。

检测程序终止

def checkForQuit():
    for event in pygame.event.get(QUIT): # 获取所有的退出(QUIT)事件
        terminate() # 只要有退出事件,程序就终止
    for event in pygame.event.get(KEYUP): # 获取所有的松键事件
        if event.key == K_ESCAPE:
            terminate() # 只要 Esc 松键,程序就终止
        pygame.event.post(event) # 如果不终止,就把其他松键的事件对象放回事件序列

“程序终止”可按手动与否来划分。
非手动的程序终止:系统检测到退出事件。
手动的程序终止:系统检测到触发程序终止的操作。

分数和升级

def calculateLevelAndFallFreq(score):
    # 在分数的基础上返回当前的等级和方块自动下落间隔
    level = int(score / 10) + 1
    fallFreq = 0.27 - (level * 0.02)
    return level, fallFreq

分数、等级和方块自动下落间隔都是用数值表示的,只需列出等式,不需调用新的函数。
int(score / 10) 相当于每消 10 行升级,“+1” 是因为开局已有 1 级。
fallFreq = 0.27 - (level * 0.02) 相当于开局的方块每 0.27 秒下降一格,每升一级加快 0.02 秒。
如果等级有 14 或更高,自动下落间隔会变成负数,但这是可以接受的,程序不会出错。
这是因为,“if time.time() - lastFallTime > fallFreq:” 只要求两个时刻的差值大于 fallFreq。
0 总是大于负数,所以游戏时刻每次推进,方块都自动下落一格,具体费时取决于每秒帧数(FPS)。
特别地,手动修改此处的两个等式,就可以改变这个方块游戏的升级规则和“重力曲线”。

生成新方块

def getNewPiece():
    # 返回新方块,颜色和朝向随机
    shape = random.choice(list(SHAPES.keys()))
    newPiece = {'shape': shape,
                'rotation': random.randint(0, len(SHAPES[shape]) - 1),
                'x': int(BOARDWIDTH / 2) - int(TEMPLATEWIDTH / 2),
                'y': -2, # 从场地上方入场(y < 0)
                'color': random.randint(0, len(COLORS)-1)}
    return newPiece

刚入场的新方块(newPiece)由字典数据类型表示,它有五个键值对:形状、旋转状态、横坐标、纵坐标、颜色。
第一,随机取形状用 random.choice() 函数,这个函数只接受列表数据,要用 list(SHAPES.keys() 实现字典转列表。
第二,随机取旋转状态可用 random.randint() 函数,因为旋转状态全是整数序号,最小是 0,最大是 len(SHAPES[shape]) - 1。
(此处只需要使用 PIECES 常量的形状和旋转状态索引,不需要像“S_SHAPE_TEMPLATE”那样存储模板字符串值列表)
第三第四,方块生成位置是顶端居中,横坐标等于半场宽减去半模板宽,纵坐标取稍高于天花板的 -2。
第五,随机颜色和随机旋转状态原理相同且更简单,因为颜色是用元组表示的,可以直接被 len(),不需转换数据类型。

把当前方块添加至方块堆

def addToBoard(board, piece):
    # 基于当前方块的位置、形状和旋转状态,填充场内空格
    for x in range(TEMPLATEWIDTH):
        for y in range(TEMPLATEHEIGHT):
            if PIECES[piece['shape']][piece['rotation']][y][x] != BLANK:
                board[x + piece['x']][y + piece['y']] = piece['color']

方块着陆后,调用 addToBoard() 函数,将方块数据结构添加至场地数据结构。
对于模板内的所有 x 和 y,非空白(!= BLANK)格就是有内容的格,把这些格的颜色信息添加至场地数据结构,方块就“锁定”了。
5×5 模板在场内的坐标是以左上角的 (0,0) 为准的,而方块上面具体的一格的坐标本是参照模板这个子体系来标注的。
所以,方块锁定时,要完全参照场地坐标系,也就是模板坐标 x 和格子坐标 ['x'] 相加,y 和 ['y'] 相加。
每次着陆,都要像这样换算全部 25 个格子的列表数据。
特别地,如果方块在很高的位置着陆,高出天花板的格子不会被转换颜色信息,于是它们一锁定就“消失”了。

清空场地

def getBlankBoard():
    # 创建空白的场地数据结构并返回值
    board = []
    for i in range(BOARDWIDTH):
        board.append([BLANK] * BOARDHEIGHT)
    return board

场地数据结构是列表值的列表。
如果一个格的值是 BLANK,这个格就是空格。
若为整数,它就是颜色常量列表的索引了:0 是蓝色,1 是绿色,2 是红色,3 是黄色。
新创建的场地什么也没有,用 append() 函数在场地数据列表末尾添加新的对象,就能赋予场地实际内容。
此处所赋的内容是“向场宽中的每一列添加场高行数的空格”,也就是 10×20 个空格。
将这个值返回,就实现了清空场地的效果。

检查方块的出界和重叠

def isOnBoard(x, y):
    return x >= 0 and x < BOARDWIDTH and y < BOARDHEIGHT

这是一个检测横纵坐标的函数,它能帮助判断方块的出界情况。
Tetromino 使用 10×20 场地,横坐标的正常范围是 0–9,纵坐标 0–19。
一个格子如果处于这个范围之内,就算是“在场上”。

def isValidPosition(board, piece, adjX=0, adjY=0):
    # 方块不出界也不重叠就返回真
    for x in range(TEMPLATEWIDTH):
        for y in range(TEMPLATEHEIGHT):
            isAboveBoard = y + piece['y'] + adjY < 0
            if isAboveBoard or PIECES[piece['shape']][piece['rotation']][y][x] == BLANK:
                continue
            if not isOnBoard(x + piece['x'] + adjX, y + piece['y'] + adjY):
                return False
            if board[x + piece['x'] + adjX][y + piece['y'] + adjY] != BLANK:
                return False
    return True

“有效的”方块运动目标位置需要全是场内的空格。这就有了另外两个要求:
第一,方块不能出界。由前面方块堆数据结构转换的原理可知,方块只能在那个 10 × 20 的范围内传递自身的颜色信息。
第二,方块不能(和方块堆)重叠。“俄罗斯方块”在玩法概念上是不允许方块直接穿墙和缩地的,此处需要模拟出这一点。
这一步和方块着陆时的判断是同理的,它要把格子坐标从模板体系换算至场地体系,即 x + ['x'] 和 y + ['y']。
出界即对照 isOnBoard 的范围,不在范围内就返回“假”,否则返回“真”;
重叠即检查目标新位置是否为空格,不是空格就返回“假”,否则返回“真”。
此处和着陆一样,模板内的全部 25 格都要检测,空格用 continue 跳过,格子参与两步关键真假判断。
方块是从更高处入场的,在场地范围之上(isAboveBoard)的格子不参与真假判断。

检查和执行消行

def isCompleteLine(board, y):
    # 一行全是格子、没有空隙,就返回“真”
    for x in range(BOARDWIDTH):
        if board[x][y] == BLANK:
            return False
    return True

一行 10 格全部填满,这一行的消除就是“真”。
为了判断出这一点,还有另一种更简洁的等效逻辑:只要在这一行内检测到了空格,就返回“假”。

def removeCompleteLines(board):
    # 移除场上填满的行,上方内容随之下移,返回被消行数的值
    numLinesRemoved = 0
    y = BOARDHEIGHT - 1 # 从场地最下方一行开始检查是否填满
    while y >= 0:
        if isCompleteLine(board, y):
            # 移除这一行,更高处的格子下移一行
            for pullDownY in range(y, 0, -1):
                for x in range(BOARDWIDTH):
                    board[x][pullDownY] = board[x][pullDownY-1]
            # 最顶上一行调成空白
            for x in range(BOARDWIDTH):
                board[x][0] = BLANK
            numLinesRemoved += 1
            # 整个循环的下一次迭代取相同的 y 值,更高处同时发生的消行一并移除
        else:
            y -= 1 # 继续检查更上方的一行
    return numLinesRemoved

消行的逻辑:自下而上地遍历全场方块堆数据结构,一行之内查不到空格就消除,更上方的格子下移,补上全空的顶行。
“自下而上地遍历全场”有三处关键代码:y = BOARDHEIGHT - 1、while y >= 0、else y -= 1(从 19 开始减到负数为止)。
“更上方的格子下移”有两处关键代码:range(y, 0, -1)、board[x][pullDownY] = board[x][pullDownY-1](同理,从 y 开始)。
在迭代的主代码部分,只要检测到了消行,下一次迭代时的 y 是不会变的。
这是因为,代码最后写了,只有“else”(其他情况,也就是检测不到消行)时,y 才 -= 1。
如果不这样控制,发生了消行还去放任它 y -=1,只要遇到在最底下消二三四的情况,这段代码就会漏查。

把场地坐标转换成像素坐标

def convertToPixelCoords(boxx, boxy):
    # 把场地的 xy 转换成屏幕上的 xy
    return (XMARGIN + (boxx * BOXSIZE)), (TOPMARGIN + (boxy * BOXSIZE))

场地的方块堆坐标只是一个概念,基数全是 1,如果不加以放大,玩家是很难看清楚的。
像素坐标是从整个游戏窗口的左上角开始的,所以 xy 除了按单格像素数来放大以外,还要分别加上侧边距和顶边距。

显示格子

def drawBox(boxx, boxy, color, pixelx=None, pixely=None):
    # 按 xy 坐标在场上画一个格子(每个方块有四个格子)
    # 如果已经指定 pixelx 和 pixely,就画出预览块
    if color == BLANK:
        return
    if pixelx == None and pixely == None:
        pixelx, pixely = convertToPixelCoords(boxx, boxy)
    pygame.draw.rect(DISPLAYSURF, COLORS[color], (pixelx + 1, pixely + 1, BOXSIZE - 1, BOXSIZE - 1))
    pygame.draw.rect(DISPLAYSURF, LIGHTCOLORS[color], (pixelx + 1, pixely + 1, BOXSIZE - 4, BOXSIZE - 4))

方块图像显示在两处:场内方块、预览块。
场内方块的坐标换算方法已在前面定义,它随方块的运动而不断发生变化,不适合专门指定;
单个预览块则不一样,它总是使用同一个坐标,适合专门指定(pixelx、pixely)。
由此可得到显示格子的逻辑:场上方块按 convertToPixelCoords(boxx, boxy) 确定坐标,预览块专门指定一个坐标。
把图像落实到具体的单个格子上时,要方便玩家的判断,为单个格子画出边线和阴影,这可以用图层的手法来实现:
在格子的像素范围(BOXSIZE)内,先内缩 1 像素绘制深色方形[注 8],再右下多内缩 3 像素绘制浅色方形盖上去。

显示场内的所有内容

def drawBoard(board):
    # 绘制场地的边线
    pygame.draw.rect(DISPLAYSURF, BORDERCOLOR, (XMARGIN - 3, TOPMARGIN - 7, (BOARDWIDTH * BOXSIZE) + 8, (BOARDHEIGHT * BOXSIZE) + 8), 5)

    # 填充场地的背景
    pygame.draw.rect(DISPLAYSURF, BGCOLOR, (XMARGIN, TOPMARGIN, BOXSIZE * BOARDWIDTH, BOXSIZE * BOARDHEIGHT))
    # 在场上一个个地绘制出格子
    for x in range(BOARDWIDTH):
        for y in range(BOARDHEIGHT):
            drawBox(x, y, board[x][y])

都通过 DISPLAYSURF 实现。
场地边线:初始设置为蓝色,向四周适当外扩少量像素数,画出一个比场地稍大的蓝色矩形。
场地背景:按场地的实际像素数盖上黑色作为背景,剩下的一圈蓝色就是边线了。
单个格子:在这个有蓝边包围的 10×20 黑色场地中按 drawBox() 绘制格子、覆盖背景。

显示分数和等级

def drawStatus(score, level):
    # 绘制分数文本
    scoreSurf = BASICFONT.render('Score: %s' % score, True, TEXTCOLOR)
    scoreRect = scoreSurf.get_rect()
    scoreRect.topleft = (WINDOWWIDTH - 150, 20)
    DISPLAYSURF.blit(scoreSurf, scoreRect)

    # 绘制等级文本
    levelSurf = BASICFONT.render('Level: %s' % level, True, TEXTCOLOR)
    levelRect = levelSurf.get_rect()
    levelRect.topleft = (WINDOWWIDTH - 150, 50)
    DISPLAYSURF.blit(levelSurf, levelRect)

分数和等级都是会变的字符内容,用字符串格式化(%s)的形式表示。
剩下的就是自定边距合适的字符矩形区域,把这两处文本内容 DISPLAYSURF 出来。

显示方块

def drawPiece(piece, pixelx=None, pixely=None):
    shapeToDraw = PIECES[piece['shape']][piece['rotation']]
    if pixelx == None and pixely == None:
        # 和“显示格子”同理,按场内方块和预览块分开讨论
        pixelx, pixely = convertToPixelCoords(piece['x'], piece['y'])

    # 绘制全部四个格子,做出四连方块
    for x in range(TEMPLATEWIDTH):
        for y in range(TEMPLATEHEIGHT):
            if shapeToDraw[y][x] != BLANK:
                drawBox(None, None, piece['color'], pixelx + (x * BOXSIZE), pixely + (y * BOXSIZE))

“显示格子”针对的是场地,“显示方块”针对的则是 5×5 模板。
基于方块的形状(['shape'])和旋转状态(['rotation'])这两个列表数据检测模板内全部 25 格,是空格就跳过,是格子就上色。

显示预览块

def drawNextPiece(piece):
    # 绘制“NEXT:”文本
    nextSurf = BASICFONT.render('Next:', True, TEXTCOLOR)
    nextRect = nextSurf.get_rect()
    nextRect.topleft = (WINDOWWIDTH - 120, 80)
    DISPLAYSURF.blit(nextSurf, nextRect)
    # 绘制预览块
    drawPiece(piece, pixelx=WINDOWWIDTH-120, pixely=100)

和前面几处同理。
自定边距合适的字符矩形区域显示出“Next:”文本,用 drawPiece() 函数在指定的 pixelx 和 pixely 位置显示出预览块。

运行模块

if __name__ == '__main__':
    main()

如果直接运行“tetromino.py”,游戏程序就执行“main()”的内容。

其他说明

这个游戏需要加载名为 tetrisb 和 tetrisc 的两个 mid 文件。
作者 Albert Sweigart 在 invpy.com 提供了这两个文件的下载:tetrisb.midtetrisc.mid
或者,将任意合规的 mid 文件改名置于同一目录,也可以正常游戏。

Pentomino (Python, Albert Sweigart, 2012) ingame.png

Tetromino for Idiots (Python, Albert Sweigart, 2012) ingame.png

“tetromino.py”还有两个改版,一个是五连方块,一个是单格方块。
获取地址:本条目第一个外链的原文正文的最后两个链接。

注释

  1. 实际场宽 = 格数 × 格宽
  2. 作为解释性语言,Python 不像 C++、Java 等编译性语言那样必须有一个 main 函数。在 Python 中写 def main() 更多地是在强调接下来要定义的内容将会在某种意义上居于主要地位。例如,此处是在定义 Tetromino 进入游戏后的标题画面的一些东西,它们“居于主要地位”。
  3. 对游戏程序而言,“松键”是重要的判断依据。例如,因为松开了向左,所以方块才停止向左移动。
  4. 对负数有效。例:J 块逆时针旋转,(-1) % 4 = (-1) * 4 + 3,结果为旋转状态四。
  5. pygame 模块的二维场地的直角坐标系的第一象限在原点的右下方。
  6. 字色已在“颜色初始设置”中定义。
    在后面的场地上色部分,边线和背景同理上色。
  7. 包括松键操作。如果是长按着操作输掉游戏的,只要把键松开,下一局就会开始。
  8. 游戏背景是黑色的,这个内缩 1 像素相当于利用背景的黑色完成了方块的黑色边线。

外链