今天咱们正式开始小游戏开发之旅,首先制作的就是最常见的俄罗斯方块。记得小时候第一次接触俄罗斯方块的时候还是在我亲戚家,当时一下子就被吸引了,最主要的不是因为俄罗斯方块多好玩,而是木有其它游戏机可以玩……,现在回忆起来,感觉自己从那个时候就已经透漏出以后肯定一事无成的现象了,记忆里我总是想去玩,但每次玩着玩着又觉得枯燥,转手就扔了,过了不久再回来玩,难道我三天打鱼两天晒网的性格是那时候养成的?😱😱😱
不扯蛋了,回归正题,俄罗斯方块的核心玩法非常简单,所以制作起来并不是很复杂,我准备用两篇文章详细讲解一下俄罗斯方块的制作方法:
- 第一篇,也是本篇文章,主讲俄罗斯方块的基本开发过程,包括如何定义方块、如何实现方块的下落、如何实现方块的消除等等。
- 第二篇,主讲俄罗斯方块的一种简单 AI 算法,毕竟俄罗斯方块是一个很成熟的游戏了,完全可以靠自己玩下去。
名词定义
俄罗斯方块中的基本逻辑非常简单,不过在介绍之前,我们先确定一下名词,防止出现词不达意的现象。
小方块
俄罗斯方块中的最小单位,构成容器的基本单位,我们称它为小方块。
大方块
由 4 个小方块组成,用来填充容器的东西,我们叫做大方块。大方块一共有 7 种,每种方块包含 4 种状态。
容器
用来存放大方块的地方,我们叫做容器,传统的俄罗斯方块是一个 20 x 10 的矩形。
需求分析
定义了三种东西之后,我们可以这样描述俄罗斯方块:
俄罗斯方块主要由一个存放正方形小方块的虚拟容器和实时下落的 7 种大方块构成。容器的宽是 10 列,高是 20 行,其中每行的高度和每列的宽度是相等的。所以,这个虚拟容器可以看作是 200 个正方形小方块平铺的结果。
每个实时下落的大方块都是由 4个正方形小方块组成,共 7 种固定样式,每种方块都可以旋转,所以理论上最多有 28 种样式,但其中有一些大方块在旋转的时候样式不会发生变化。
7 种大方块按照形象可以由 7 种字母替代,它们是:S、Z、L、J、I、O、T。这些大方块在下落到容器最底部或碰撞到其它容器中的小方块时会被固定在容器中,然后容器上方会重新产生一个随机的大方块,重复原来的下落流程。当容器中出现满行的时候,整行会被消除,该行上方的所有小方块都会依次掉落。
大方块的定义
俄罗斯方块中虽然基本的单位是小方块,但是所有操作的对象都是大方块,所以这里直接定义大方块的结构类型。
上图是大方块的所有状态,你可以根据图片中的变化来定义相应的大方块。
不过在那之前,需要先定义两个结构,分别是大方块的类型和大方块的旋转状态。
// 方块类型
typedef enum BlockType
{
BT_S = 0,
BT_Z,
BT_J,
BT_L,
BT_I,
BT_T,
BT_O,
BT_NUM
} BlockType;
// 方块状态
typedef enum BlockState
{
BS_T = 0,
BS_R,
BS_B,
BS_L,
BS_NUM
} BlockState;
组合使用上述两个类型,就可以确定大方块的所有形状。除此之外,因为大方块会按照固定的速度移动,因此还需要定义大方块的位置信息,具体结构如下:
// 大方块
typedef struct Block
{
int row; // 方块水平位置
int col; // 方块垂直位置
BlockType type; // 方块类型
BlockState state; // 方块状态
} Block;
这里之所以没有定义大方块的下落速度是因为所有的大方块下落速度都是一样的,因此不需要在每个大方块内部都定义一个变量,只需要在外部定义一个共用变量即可,这个变量的值会随着等级的变化而变化。一般情况下,等级越高,速度越快。
当前的数据结构已经可以表达大方块的基本样式了,但是这还不够,因为数据结构中记录的只是一个大方块的当前状态,但是大方块的具体形状信息却还没有被记录。我们只知道大方块的类型和状态,却不知道如何显示它们。
为了解决这个问题,我们需要做一个简单的映射,这样就确定大方块处于某种状态下的具体形状。记录这种映射信息的方法被称为打表法。简单的说,我们定义一张表格,表格中记录了大方块的所有形状,一共 28 种(4 * 7 = 28,包括重复的)。如果程序执行过程中需要知道当前大方块的形状,可以直接来这张表格中查找即可。
这张表格被定义为全局的静态数组,数组的每一项代表一种方块的形状,算上重复的,一共可存放 28 种形状。
static unsigned short gBlockList[BT_NUM][BS_NUM];
注意这里,数组的每一项用了一个 unsigned short 类型表示。
每个大方块都可以用 4 x 4 的二维数组表示,二维数组的每一项代表当前位置是否需要显示小方块。换句话说,每个位置只有两种状态,0 表示隐藏,1 表示显示。将二维数组的每一项都换成一个比特位,那么二维数组就可以压缩为一个长度为 16 位的 unsigned short 类型。
确认了表示方法,我们还需要研究一下存储方式。按照现在的逻辑,相当于二维数组转换位一维数组,因此需要确认数组的第一行放到左边还是右边的问题,换句话说,是放到高位还是放到低位的问题。这是看个人喜好,只要程序中一致即可,因为这个顺序关系到后期方块绘制的逻辑。
我们这里用低 4 位表示大方块的第一行,具体掩码如下:
[ 0 0 0 0 ] 0x000F
[ 0 0 0 0 ] 0x00F0
[ 0 0 0 0 ] 0x0F00
[ 0 0 0 0 ] 0xF000
按照这个算法写了一个互动程序,大家可以体验一下:
最终我们可以求出 28 个形状对应的每一个值:
static unsigned short gBlockList[BT_NUM][BS_NUM] = {
{0x0630, 0x0132, 0x0630, 0x0132}, //S
{0x0360, 0x0231, 0x0360, 0x0231}, //Z
{0x0170, 0x0223, 0x0074, 0x0622}, //J
{0x0470, 0x0322, 0x0071, 0x0226}, //L
{0x00F0, 0x2222, 0x00F0, 0x2222}, //I
{0x0270, 0x0232, 0x0072, 0x0262}, //T
{0x0660, 0x0660, 0x0660, 0x0660} //O
};
以后如果在程序中如果需要获取当前大方块的形状,只需要使用类似下面的语句:
gBlockList[i][j]
大方块的基本操作
确定了大方块的数据结构,接下里我们实现大方块的基本操作。首先是初始化操作:
void initRandBlock(Block* block, int r, int c)
{
block->row = r;
block->col = c;
block->type = rand() % BT_NUM;
block->state = rand() % BS_NUM;
}
我们这里随机一种方块样式赋值即可。
接着就是移动操作,因为我们使用两个变量来确定大方块的位置,所以代码逻辑非常简单:
//左移
int moveLeft(Block *block)
{
if (block)
{
block->col -= 1;
}
return 0;
}
//右移
int moveRight(Block *block)
{
if (block)
{
block->col += 1;
}
return 0;
}
//下移
int moveDown(Block *block)
{
if (block)
{
block->row += 1;
}
return 0;
}
除了移动,大方块还有旋转操作,因为前面我们已经使用打表法定义了所有方块的旋转状态,这里直接获取即可:
//旋转
int rotate(Block* block)
{
block->state = (block->state + 1) % BS_NUM;
return 0;
}
上面这些只是大方块的简单操作,复杂的例如碰撞检测以及渲染等操作我们将其放到后面,等讲完容器后再继续。
容器的表示
大方块的实现涉及到位运算,而容器同样如此。容器显示的部分是由 10 * 20 个小方块构成的矩形,如果我们将每个小方块用一个比特来表示,则一行只需要 10 比特,C语言中可以用 unsigned short 表示,不过这里我们为了后期扩展,选用了 unsigned long 类型。
unsigned long blockContainer[TETRIS_CONTAINER_HEIGHT];
blockContainer 变量代表整个容器,TETRIS_CONTAINER_HEIGHT 代表容器的高度。这里需要注意常量 TETRIS_CONTAINER_HEIGHT 并没有定义为 20,而是定义为 25,容器的每行我们用了 12 位比特表示,并没有用 10 位表示,这所以这样,其实是为了碰撞检测带来方便,其中容器的宽高定义如下:
//俄罗斯方块容器宽高
#define TETRIS_CONTAINER_WIDTH (1 + 10 + 1)
#define TETRIS_CONTAINER_HEIGHT (BLOCK_HEIGHT + 20 + 1)
这里我们用容器的示意图表示一下这样定义的好处:
上图是俄罗斯方块真正的容器区域,其中游戏界面显示的仅仅是其中的蓝色显示区域,而绿色隐藏区域用来放置准备下落的大方块,而灰色是用来碰撞检测的隔离区域。
因为 Windows 窗口的纵坐标是从上到下的,所以我们显示的时候也是从上到下,最上边是容器的第 0 行,最下边是容器的 24 行,这一行会用来兜底,防止大方块在下落的过程中越界。
用上面这种定义方式,可以简化程序逻辑,避免大量的边界检测判断。
大方块的渲染
Windows 窗口的水平坐标是从左到右的,所以左边是第 0 行,最右边是第 11 行。这里需要注意这和默认大方块表示的二进制顺序并不一样:
图中左侧是大方块的前台显示画面,而右侧是大方块内部的数据值,注意二者区别。
事实上,前台显示的画面左边是二进制的低位,右边是二进制的高位,所以大方块真正表示的二进制是和显示的画面水平方向正好是相反的。
俄罗斯方块的结构定义
明白了上面的介绍,接下来我们就可以定义俄罗斯方块的数据结构:
//俄罗斯方块
typedef struct Tetris
{
unsigned long blockContainer[TETRIS_CONTAINER_HEIGHT]; // 容器
int blockIndex; // 当前块索引
Block blocks[TETRIS_BLOCK_NUM]; // 多个块(前后台)
//......
} Tetris;
俄罗斯方块中成员很多,但最重要的就是容器和两个方块的表示,之所以是两个方块是因为一个是当前下落的前台方块,另一个是下轮下落的后台方块,这里用数组表示,然后增加一个方块索引,用来循环使用。
代码中的 TETRIS_BLOCK_NUM
表示当前俄罗斯方块的数量,默认是 2,一个是前台正在操作的,另一个是下一次显示的,如果不懂可以看下面的示例:
注意看图片右侧部分,这里存在 3 个后台方块,如果算上前台,那么在这个程序中 TETRIS_BLOCK_NUM
将被定义为 4。
俄罗斯方块初始化
有了数据结构之后,接下来可以实现俄罗斯方块的基本操作了。首先当然是初始化操作:
//初始化容器
for (int i = 0; i < TETRIS_CONTAINER_HEIGHT; i++)
{
tetris->blockContainer[i] = EMPTY_LINE;
}
tetris->blockContainer[TETRIS_CONTAINER_HEIGHT - 1] = 0xFFFF;
//初始化方块
tetris->blockIndex = 0;
for (int i = 0; i < TETRIS_BLOCK_NUM; i++)
{
initRandBlock(&(tetris->blocks[i]), BLOCK_INIT_POSY, BLOCK_INIT_POSX);
}
代码中逻辑就是将容器初始化为前面的示意图状态,其中定义了三个常量:
const int BLOCK_INIT_POSX = (TETRIS_CONTAINER_WIDTH - BLOCK_WIDTH) / 2;
const int BLOCK_INIT_POSY = 2;
const unsigned long EMPTY_LINE = 0x0801;
前两个用来表示方块初始化的位置,后面则是值容器中空行的数值。
碰撞合并逻辑
初始化完成之后,我们接下来实现大方块和容器的碰撞操作以及大方块和容器发生碰撞后的合并操作。首先是碰撞操作:
//碰撞测试
int hitTest(const Block *block, const Tetris *tetris)
{
unsigned short blk = gBlockList[block->type][block->state];
for (int i = 0; i < BLOCK_HEIGHT; i++)
{
unsigned short bits = ((blk >> (i * BLOCK_WIDTH)) & 0x000F);
//block->col 可能为负数
if (block->col < 0)
{
bits >>= (-block->col);
}
else
{
bits <<= block->col;
}
if (tetris->blockContainer[block->row + i] & bits)
{
return 1;
}
}
return 0;
}
代码主要逻辑是一个 for 循环,本质含义就是查看大方块的每一行,变量 bits 表示大方块的某一行所代表的比特值,最后用这个串值和容器的值比较(&运算),相撞则返回 1 。
碰撞测试中首先获取当前大方块,然后根据大方块的位置,查看大方块和容器是否有重合的地方,逻辑上就是检测容器和大方块相同的位置比特位是否同时为 1。这里有个地方需要注意,大方块的水平位置可能为负,例如下面这种情况:
上图是 I 形的大方块,在竖起的状态下可能呈现出上面的效果,当前这个方块的列为 -1。事实上你可以通过规划大方块的形状和位置来避免这类问题,只不过这里没有这样做,而是直接将负数列作为正常的情况之一。
接下来是碰撞后的合并,操作很简单就是直接将大方块的比特位复印到容器内即可,在位运算上可以使用或运算实现。
//合并
void merge(Block *block, Tetris *tetris)
{
unsigned short blk = gBlockList[block->type][block->state];
for (int i = 0; i < BLOCK_HEIGHT; i++)
{
unsigned short bits = ((blk >> (i * BLOCK_WIDTH)) & 0x000F);
//block->col 可能为负数
if (block->col < 0)
{
bits >>= (-block->col);
}
else
{
bits <<= block->col;
}
tetris->blockContainer[block->row + i] |= bits;
}
}
操控大方块
接下来实现大方块的操控函数,主要有左移、右移、下移、旋转以及掉落。这些其实以及在上一篇文章讲过了,这次做的是加上碰撞逻辑,例如当左移动的时候:
//左移
int moveLeftBlock(Tetris* tetris)
{
if (!tetris) {
return -1;
}
//当前方块
Block* currBlock = &(tetris->blocks[tetris->blockIndex]);
//移动后的状态
Block next = *currBlock;
moveLeft(&next);
//检测下一状态的方块会发生碰撞,则取消移动
if (hitTest(&next, tetris)) {
return 0;
}
//没发生碰撞,完成移动
moveLeft(currBlock);
return 0;
}
我们首先获取大方块的状态,然后模拟出大方块左移后的效果,用左移后的方块做碰撞检测,如果发生碰撞,则直接返回,否则将当前的方块左移,整个过程有点类似于投石问路的效果。
其它的操作和左移基本类似,除了下移操作需要在发生碰撞的时候进行合并操作并生成新的方块:
//下移
int moveDownBlock(Tetris* tetris)
{
if (!tetris) {
return -1;
}
//当前方块
Block* currBlock = &(tetris->blocks[tetris->blockIndex]);
if (moveDownTest(tetris, currBlock))
{
//如果底部碰撞,则将方块合并到容器中
merge(currBlock, tetris);
//消行
eraseLines(tetris);
//重新生成方块,并切换当前方块
initRandBlock(currBlock, BLOCK_INIT_POSY, BLOCK_INIT_POSX);
tetris->blockIndex = (tetris->blockIndex + 1) % TETRIS_BLOCK_NUM;
return 1;
}
//没发生碰撞,完成移动
moveDown(currBlock);
return 0;
}
这里面还有一个上面没有说的消行函数,消行本身非常简单,只需要检测当前容器行是否满足“满行”即可,如果满足,则删除该行,让容器其它行依次移动到这里:
//消减行
static void eraseLines(Tetris* tetris)
{
//从下到上,逐步扫描
int line = TETRIS_CONTAINER_HEIGHT - 2;
int afterLine = line;
int eraseLine = 0;
while (line >= BLOCK_HEIGHT)
{
//如果当前不满行
if (0x0FFF != (tetris->blockContainer[line] & 0x0FFF))
{
afterLine--;
}
//记录消行数
else
{
eraseLine++;
}
line--;
if (afterLine != line)
{
tetris->blockContainer[afterLine] = tetris->blockContainer[line];
}
}
//剩余设置为空
while (afterLine >= BLOCK_HEIGHT)
{
tetris->blockContainer[--afterLine] = EMPTY_LINE;
}
}
eraseLine 变量代表最终消行数,你可以用这个值计算一些分数等等。
更新与绘制
完成了周边的操作函数,接下来就是让程序自身动起来,这里直接在更新函数中增加一个不断更新的下落操作就能实现:
//处理游戏逻辑
while (tetris->tick >= tetris->speed) {
// 下落
moveDownBlock(tetris);
tetris->tick -= tetris->speed;
}
tick 变量代表游戏运行中的滴答时间,单位是毫秒。而 speed 代表当前的下落速度,这个单位也是毫秒,代表经过多少毫秒下落一次,更新函数每次检测当前等待的时间是否大于下落速度,大于则执行下落操作。
绘制操作很简单,只是单纯的调用 SDL 显示数据结构中的数据而已。下面是绘制容器的操作:
//绘制容器
for (int i = BLOCK_HEIGHT, r = 0; i < TETRIS_CONTAINER_HEIGHT - 1; i++, r++)
{
for (int j = 1, c = 0; j < TETRIS_CONTAINER_WIDTH - 1; j++, c++)
{
rtDst.x = c * BLOCK_IMAGE_WIDTH;
rtDst.y = r * BLOCK_IMAGE_HEIGHT;
rtDst.w = BLOCK_IMAGE_WIDTH;
rtDst.h = BLOCK_IMAGE_HEIGHT;
SDL_RenderCopy(pModule->pRenderer,
getResource(RES_TEXTURE), getTileRect(TT_BK), &rtDst);
//当前位置有方块(i,j)
if (tetris->blockContainer[i] & (1 << j))
{
SDL_RenderCopy(pModule->pRenderer,
getResource(RES_TEXTURE), getTileRect(TT_FK), &rtDst);
}
}
//绘制右侧竖线
SDL_Rect rtLineSrc = {0, 0, 5, BLOCK_IMAGE_HEIGHT};
SDL_Rect rtLineDst = {(TETRIS_CONTAINER_WIDTH - 2) * BLOCK_IMAGE_WIDTH + 3,
r * BLOCK_IMAGE_HEIGHT, 5, BLOCK_IMAGE_HEIGHT};
SDL_RenderCopy(pModule->pRenderer, getResource(RES_TEXTURE), &rtLineSrc, &rtLineDst);
}
下面是绘制大方块的操作:
void renderBlock(Block *block, unsigned char alpha, SystemModule *pModule)
{
SDL_Rect rt = {0, 0, BLOCK_IMAGE_WIDTH, BLOCK_IMAGE_HEIGHT};
for (int i = 0; i < BLOCK_HEIGHT; i++)
{
for (int j = 0; j < BLOCK_WIDTH; j++)
{
//如果当前位置有方块
if ((1 << j << (i * BLOCK_WIDTH)) & (gBlockList[block->type][block->state]))
{
rt.x = (block->col + j - 1) * BLOCK_IMAGE_WIDTH;
rt.y = (block->row + i - BLOCK_HEIGHT) * BLOCK_IMAGE_HEIGHT;
SDL_SetTextureAlphaMod(getResource(RES_TEXTURE), alpha);
SDL_RenderCopy(pModule->pRenderer, getResource(RES_TEXTURE), getTileRect(TT_FK), &rt);
SDL_SetTextureAlphaMod(getResource(RES_TEXTURE), 255);
}
}
}
}
整个俄罗斯方块的基本逻辑就这些,最后在加上一点细节,例如分数,等级、音乐等等。如果想要试玩最终的制作效果可以去下载最终的游戏成品。