我一直认为,编写游戏,是python初学者寻求进阶的一个有效途径。俄罗斯方块是一款很老的游戏,但对于python初学者而言还是有相当的难度。
目前看到网上的大部分实现都是面向过程的,虽然能够实现功能,但是整个程序的可扩展性和可维护性都很差,而且写这样的程序,对我们这些初级程序员的提高是有限的。
我们期望能够构建出结构清晰,扩展性强的,容易维护的代码,而不是所有逻辑和细节都糅合在一起的超级大杂烩。
最终效果
俄罗斯方块
初步设计
游戏面板(Board):15 * 25 的方格,操作在游戏面板中进行;
形状(Shape):一共有7种形状:L、J、T、Z、S、I、O
形态(Style):每种形状都可以进行旋转,旋转后呈现不同的形态(或者方向),例如L形状,就有4种形态。
方格(Cell):游戏面板和形状都是由方格组成的,是最小单位。在初步设想中,方格有两个状态:填充和空白,看上去我们可以用简单的0和1来表示方格,即1表示填充,0表示空白;不过稍微思考下,方格的状态其实有3种:1.活动方格;2.非活动方格(着陆方格);3.空白方格。
因此我们这样来表示3种状态(这里用了二进制来表示显得规整一点,其实就是0,1,2三个值):
# CELL STATE
BLANK = 0b00
ACTIVE = 0b01
LANDED = 0b10
每一个Style形态用方格的BLANK和ACTIVE状态表示组成,因此简单的二维数组就能表示。比如L形状的四种形态,可以表示成:
s1 = [[1, 1, 1],
[1, 0, 0]]
s2 = [[1, 1],
[0, 1],
[0, 1]]
s3 = [[0, 0, 1],
[1, 1, 1]]
s4 = [[1, 0],
[1, 0],
[1, 1]]
这样形状L以及4种形态就可以表示为:
L = [s1, s2, s3, s4]
依此类推,所有的7种形状和各自的不同形态可以表示成如下形式的常量:
# SHAPES
L = [[(1, 1, 1), (1, 0, 0)], [(1, 1), (0, 1), (0, 1)], [(0, 0, 1), (1, 1, 1)], [(1, 0), (1, 0), (1, 1)]]
J = [[(1, 1, 1), (0, 0, 1)], [(0, 1), (0, 1), (1, 1)], [(1, 0, 0), (1, 1, 1)], [(1, 1), (1, 0), (1, 0)]]
T = [[(0, 1, 0), (1, 1, 1)], [(1, 0), (1, 1), (1, 0)], [(1, 1, 1), (0, 1, 0)], [(0, 1), (1, 1), (0, 1)]]
Z = [[(1, 1, 0), (0, 1, 1)], [(0, 1), (1, 1), (1, 0)]]
S = [[(0, 1, 1), (1, 1, 0)], [(1, 0), (1, 1), (0, 1)]]
I = [[(1,), (1,), (1,), (1,)], [(1, 1, 1, 1)]]
O = [[(1, 1), (1, 1)]]
下面来看游戏面板(Board)。游戏面板是方格的集合,同样用二维数组来表示游戏面板,举个例子,如下是一个5 * 5 的空白游戏面板:
board = [[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0]]
面板中的任何一个方格,可以用坐标(x, y)表示,获取方式:
cell = board[y][x]
shape如何“画”到board上?因为shape的方格都是活动方格,所以L形状展示在面板上的方式如下:
board = [[0, 0, 0, 0, 0],
[0, 1, 1, 1, 0],
[0, 1, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0]]
而shape着陆以后,shape的方格状态变为LANDED,board就会变成如下形式:
board = [[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 2, 2, 2, 0],
[0, 2, 0, 0, 0]]
我们注意到,游戏面板虽然可以用二维数组表示出来,但是应该还需要定义一些通用的行为,比如获取或者设置某个坐标cell状态、如何把整个shape“画”到面板上方法,还有清除行、判断是否到达顶部等等可能的行为,因此我们建立一个Board类:
class Board:
def __init__(self, width, height):
self.width = width
self.height = height
self.current_shape = None
self.init_cells()
def __str__(self):
return str(self.cells)
def init_cells(self):
self.cells = []
for _ in range(self.height):
self.cells.append([BLANK] * self.width)
def get_cell(self, x, y):
return self.cells[y][x]
def set_cell(self, x, y, state=ACTIVE):
self.cells[y][x] = state
def _blit_shape(self, state):
for offset_y, row in enumerate(self.current_shape.style):
for offset_x, value in enumerate(row):
if value:
self.set_cell(self.current_shape.x + offset_x,
self.current_shape.y + offset_y, state)
@property
def is_over_top(self):
for cell in self.cells[0]:
if cell:
return True
return False
def find_full_rows(self):
index = []
for y in range(self.height -1, -1, -1):
if sum(self.cells[y]) == LANDED * self.width:
index.append(y)
return index
def clear_full_rows(self):
index = self.find_full_rows()
if (l := len(index)) > 0:
for y in index:
self.cells.pop(y)
for _ in range(l):
self.cells.insert(0, [BLANK] * self.width)
def update(self):
for y in range(self.height):
for x in range(self.width):
if self.cells[y][x] == ACTIVE:
self.cells[y][x] = 0
if self.current_shape:
self._blit_shape(ACTIVE)
说明:
- 游戏会以形状到顶而结束,因此这里定义了一个is_over_top的属性来判断是否到顶;
- 因为游戏会消除满行,所以find_full_rows用来找到满行,返回满行的索引,这里的索引必须是降序排列的;clear_full_rows用来清除满行,因为满行索引是降序,所以pop的顺序也是从后往前,这样索引就不会乱;
- 这里添加了一个属性,current_shape,表示当前面板中活动的shape(网格值为1);
- _blit_shape方法用来将当前的shape“画”到board上,若state属性为LANDED,则表示该shape已经着陆;
- 最后的update方法,是用来刷新面板的:“动的部分”是面板上活动的shape,所以每次刷新都把活动的shape清除(即把状态为ACTIVE的方格设置成BLANK),清除之后再画出来——因为此时shape会有新的位置或者新的style。
接下来构建Shape类:
class Shape:
def __init__(self, style):
self.styles = styles[style]
self.styles_len = len(self.styles)
self.i = 0
self.board = None
self.x = 0
self.y = 0
def __repr__(self):
return str(self.style)
@staticmethod
def random_shape():
return Shape(random.choice('LJTZSIO'))
@property
def i(self):
return self._i
@i.setter
def i(self, value):
self._i = value
self.style = self.styles[value]
self.length = len(self.style[0])
self.height = len(self.style)
@property
def next_style(self):
if self.i + 1 >= self.styles_len:
return self.styles[0]
return self.styles[self.i + 1]
@property
def margin_left(self):
return self.x
@property
def margin_right(self):
return self.board.width - self.x - 1
@property
def margin_bottom(self):
return self.board.height - self.y - 1
def move_left(self, value=1):
self.x -= value
def move_right(self, value=1):
self.x += value
def move_down(self, value=1):
self.y += value
def land(self):
self.board._blit_shape(LANDED)
self.board.current_shape = None
def rotate(self):
if (self.i + 1) >= self.styles_len:
self.i = 0
else:
self.i += 1
说明:
- 我们没必要给style专门构建一个类,而是把style当作一个属性;styles属性则代表了该shape的所有形状,与之相关的i属性表示当前style在styles列表中的索引,一旦设置i的值,则当前的style跟着变化;next_style表示下一次翻转的形状;
- board属性代表了当前shape所属的borad,x与y表示位置坐标;
- 几个margin属性表示了当前shape距离面板边缘的距离;
- 几个move方法则表示形状的左、右、下的移动;rotate方法表示翻转;land方法表示着陆
- 静态方法random_shape会创建一个随机的shape实例
两个最基本的类已经构建完成,接下来就是实现游戏逻辑了。我们下一章见~