俄罗斯方块
前言
俄罗斯方块游戏制作教程,一个我考虑了很久要不要发的项目,因为这个项目代码相对来说有点长,大概500行,最为致命的就是逻辑关系很复杂,想要用语言来表达很困难,最后就是文章太长了,5000字的正文啊,写的我手抽筋~
让我下定决心去写俄罗斯方块是因为加我好友的小学妹给我打微信电话

因为 其实我当时在打王者,还是晋级赛 家穷人丑,声音不好听,我不敢接,怕影响形象~

最终,我花了很长一段时间整理思路,写下了这篇5000字的干货,简述俄罗斯方块的原理及制作过程,还有源码分享,源码关键字在文末~
我的游戏我先试玩:

因为不能录制太长的视频所以我就直接挂掉游戏了,有兴趣的可以用我的源程序玩一下,源程序链接在评论区哈~
这是我昨天晚上测试的数据,感觉蛮好玩的~ (调试的时候玩的我想砸电脑)

这篇文章会很长很长,但是图文并茂,通俗易懂,对于二进制的操作还有示例解释,答应我要看到最后~
正文
01
游戏设计
俄罗斯方块图形
对于俄罗斯方块,80,90后都玩过,哪怕是00后也至少听说过,但是关于俄罗斯方块的原理,还是有必要说到说到的
首先看一下俄罗斯方块的结构体
//方块结构体
struct BLOCK
{
int dir[4];
COLORREF color;
}g_Blocks[7] = {
{ 0x0f00, 0x4444, 0x0f00, 0x4444, RED }, //I
{ 0x0660, 0x0660, 0x0660, 0x0660, BLUE }, //口
{ 0x4460,0x02E0,0x0622,0x0740,BLUE }, //L
{ 0x2260,0x0E20,0x0644,0x0470,RED }, //反L
{ 0x0c60,0x2640,0x0c60,0x2640,YELLOW }, //Z
{ 0x0360, 0x4620, 0x0360, 0x4620, YELLOW }, //反z
{ 0x4E00, 0X4C40, 0x0E40,0X4640, BLUE } //T
};在定义结构体的同时定义好了 7 种 28 类俄罗斯方块形状
可能大家看数据有点懵,没关系,我们来演示一下数据和图形的关系
首先,每种图形有四个方向,这个都看得懂,每个种类对应着一个16进制数为什么是16进制数,这个看完图片就知道了
I型俄罗斯方块:
0x0f00, 0x4444, 0x0f00, 0x4444
红色为方块形状,白色为底色,红色代表1,白色代表0
到这里是不是有点明了了呢,对的没错,用一个16进制数就能代表种图形
第一个图形:
0000
1111
0000
0000
转换一下 0000 1111 0000 0000 这个十六进制表示为0x0F00 其他六种图形






图片都到齐了,十六进制也给出来了,可以说你已经了解了俄罗斯方块队的基本原理
除了俄罗斯方块的结构体,还需要定义俄罗斯方块的信息
//方块信息
struct BLOCKINFO
{
int id; //第几个方块
int dir; //是方块中的第几个方向 0 1 2 3
char x, y; //当前格子的位置在哪里
}g_CurBlock, g_NextBlock;在这里还定义了两个方块,g_CurBlock方块和g_NextBlock方块
g_CurBlock方块需要绘制在游戏区,g_NextBlock方块绘制在等待区,整个游戏就是在不断绘制这两个方块

俄罗斯方块的状态
//图像下落中的状态
enum FLAG {
SHOW,//正常显示
CLEAR,//擦除
FIX//固定
};需要实现的功能都枚举出来
enum OPERATE
{
ROTATE //旋转 ↑键
,LEFT //左移 左键
,RIGHT //右移 右键
,DOWN //下移 ↓键
,SINK //下沉 空格键
,QUIT //退出 Esc键
,STOP //暂停 o键
};然后就是定义地图和成绩
#define WIDTH 10 //地图的宽
#define HEIGHT 22 //地图的高
#define PIX 20 //像素
int g_map[HEIGHT][WIDTH] = { 0 };
int g_score;02
主函数
int main()
{
initgraph(640, 480);
init(); //初始化游戏
while (1)
{
Select(); //功能选择
}
closegraph();
return 0;
}游戏三部曲
1、加载游戏数据(初始化 init() ;)
2、绘制图形(绘图 DrawBlock() ;)
3、玩家操作(数据更新 Select() ;)
模式还是一样,唯一的区别就是以前是不断绘制一个画面,现在是不断绘制每一个方块,有点难理解,先往下看
初始化函数init()
void init()
{
srand((unsigned)time(NULL)); //设置随机种子
setbkmode(TRANSPARENT); //设置背景颜色为透明
setorigin(0, 0); //设置坐标原点
cleardevice(); //清屏
//显示文字
settextstyle(14, 0, L"宋体");
...
...
outtextxy(20, 440, L"esc: 退出程序");
//画两个矩形
setorigin(220, 20);
setlinecolor(WHITE);
rectangle(-1, -1, WIDTH*PIX, HEIGHT*PIX);
rectangle((WIDTH + 1)*PIX - 1, 0, (WIDTH + 6)*PIX, 6 * PIX);
NewGame();
}初始化函数init()就干了两件事,绘制初始界面,然后调用NewGame()函数
开始新游戏NewGame()
void NewGame()
{
//随机产生图形
setfillcolor(BLACK);
ZeroMemory(g_map, WIDTH*HEIGHT * sizeof(int));
g_NextBlock.id = rand() % 7; //七种图形中随机一个
g_NextBlock.dir = rand() % 4; //四个方向中随机一个
g_NextBlock.x = WIDTH + 1;
g_NextBlock.y = HEIGHT - 2;
NewBlock();
}NewGame()初始化了地图,和g_NextBlock方块信息,然后执行NewBlock()函数
获取新方块NewBlock()
void NewBlock()
{
/*****第一部分*****/
g_CurBlock.dir = g_NextBlock.dir;
g_NextBlock.dir = rand() % 4;
g_CurBlock.id = g_NextBlock.id;
g_NextBlock.id = rand() % 7;
g_CurBlock.x = WIDTH / 2 - 1;
g_CurBlock.y = HEIGHT + 2;
/*****第二部分*****/
//获取g_CurBlock的方块信息
int c = g_Blocks[g_CurBlock.id].dir[g_CurBlock.dir];
while ((c & 0x000F) == 0)
{
g_CurBlock.y--;
c >>= 4;
}
/*****第三部分*****/
setfillcolor(BLACK);
solidrectangle((WIDTH + 1)*PIX, 1, (WIDTH + 6)*PIX - 1, 6 * PIX - 1);
DrawBlock(g_NextBlock, CLEAR);
DrawBlock(g_NextBlock);
DrawBlock(g_CurBlock);
}第一部分:干的事情就是将等待区的方块信息给游戏区,然后进行一些数据赋值,并重新初始化g_NextBlock方块信息
第二部分:干的事就是将游戏区的图形显示出一部分来,因为初始时图形在游戏区上面,不显示

需要将图形往下移动,显示出第一行不为0的图形来

例如:
0x0660 & 0x000f=0x0000 也就是 0
然后0x0660右移4位等于0x0066再去和0x000f相与,得到0x0006不为0说明此时可以在游戏区显示最下面一行的图形
第三部分:就是刷新等待区,绘制游戏区和等待区的图形
绘制方块DrawBlock()
void DrawBlock(BLOCKINFO block, FLAG flag)
{
int b = g_Blocks[block.id].dir[block.dir];
int x, y;
//画16个格子中的每一个
for (int i = 0; i < 16; i++, b <<= 1)
{
if (b & 0x8000)
{
x = block.x + i % 4;
y = block.y - i / 4;
if (y < HEIGHT)
DrawUnit(x, y, g_Blocks[block.id].color,flag);
}
}
}这里不是整体画方块,而是单独画每一个小方块,所有循环绘制每一个小方格,将16宫格的x,y坐标转换为每一个小方格的 x,y坐标,进入DrawUnit()函数
为什么这里是和0x8000相与呢,这是为了判断这个小方格是否需要绘制
例如 0x0660 ->0000 0110 0110 0110 &
0x8000 ->1000 0000 0000 0000 为0,说明第一个小方格不需要绘制,然后 0x0660 左移一位相与,一直到 0x0660左移为1100 1100 1100 0000时相与结果为非0,然后绘制第一个小方格,一直循环判断16次
绘制小方块DrawUnit()
void DrawUnit(int x, int y, COLORREF color, FLAG flag)
{
int left = x * PIX ;
int right = (x + 1) * PIX - 1;
int top = (HEIGHT - y - 1) * PIX;
int bottom = (HEIGHT - y) * PIX - 1;
switch (flag)
{
case SHOW: //显示
setfillcolor(color);
setlinecolor(LIGHTGRAY);
fillrectangle(left, top, right, bottom);
break;
case CLEAR://擦除
setfillcolor(BLACK);
solidrectangle(left, top, right, bottom);
break;
case FIX: //固定
setfillcolor(RGB(GetRValue(color) * 2 / 3, GetGValue(color) * 2 / 3, GetBValue(color) * 2 / 3));
setlinecolor(DARKGRAY);
fillrectangle(left, top, right, bottom);
break;
}
}绘制部分就比较简单了,有x,y坐标,只要确定逻辑坐标就可以绘制了逻辑坐标原点在游戏区的左下角

功能选择Select()
init() 初始化完成之后就进入到功能选择的循环阶段了
void Select()
{
DWORD newTime = GetTickCount();
if (newTime - oldTime > 500)
{
oldTime = newTime;
OnDown();
}
if (kbhit())
{
switch (getch())
{
case 'w':
OnRotate(); //旋转
...
...
case 'r':
Restart(); //重新开始
break;
}
}
Sleep(20);
}就是对各个功能进行匹配
03
功能实现
左移OnRight()
void OnRight()
{
BLOCKINFO temp = g_CurBlock;
temp.x++;
if (CheckBlock(temp)) //是否可以移动
{
DrawBlock(g_CurBlock, CLEAR);
g_CurBlock.x++;
DrawBlock(g_CurBlock);
}
}左移就是检查左移之后的图形是否符合规定,如果符合,就将原图像擦除,然后绘制新图形
检查方块位置是否合法CheckBlock()
bool CheckBlock(BLOCKINFO block)
{
int b = g_Blocks[block.id].dir[block.dir];
int x, y;
for (int i = 0; i < 16; i++, b <<= 1)
if (b & 0x8000)
{
x = block.x + i % 4;
y = block.y - i / 4;
if ((x < 0) || (x >= WIDTH) || (y < 0))
return false;
if ((y < HEIGHT) && (g_map[y][x]))
return false;
}
return true;
}判断是否合法就是判断每个小格子是否满足要求,有什么要求呢,一般都能猜到的就是 x ,y 坐标要在游戏区域内,有出入的就是小方格不能和全局定义的地图元素冲突,也就是游戏中方块碰到图形就不能下落了
向左向右都是一个模式,代码CV改下参数就可以了
下落OnDown()
void OnDown()
{
BLOCKINFO temp = g_CurBlock;
temp.y--;
if (CheckBlock(temp))
{
DrawBlock(g_CurBlock, CLEAR);
g_CurBlock.y--;
DrawBlock(g_CurBlock);
}
else
OnSink(); // 不可下移时,执行下沉操作
}这个下落函数在Select()功能函数里面每隔500ms执行一次,当按 ↓ 键也会执行
方块下落和左移右移不同的是当无法下移时也就是方块下面有方块时执行下沉操作
下沉
OnSink()
这个下沉函数是俄罗斯方块的除原理之外的又一个重点,也是难点
void OnSink()
{
int i, x, y;
/******第一部分******/
//连续下移方块
DrawBlock(g_CurBlock, CLEAR);
BLOCKINFO temp = g_CurBlock;
temp.y--;
while (CheckBlock(temp))
{
g_CurBlock.y--;
temp.y--;
}
DrawBlock(g_CurBlock, FIX);
/******第二部分******/
// 固定方块在游戏区
int b = g_Blocks[g_CurBlock.id].dir[g_CurBlock.dir];
for (i = 0; i < 16; i++, b <<= 1)
if (b & 0x8000)
{
if (g_CurBlock.y - i / 4 >= HEIGHT)
{ //如果方块的固定位置超出高度,结束游戏
if (MessageBox(GetHWnd(), L"是否重新开始?", L"提醒", MB_OKCANCEL | MB_ICONQUESTION) == IDOK){
init();
return;
}
else{
closegraph();
exit(0);
}
}
else
g_map[g_CurBlock.y - i / 4][g_CurBlock.x + i % 4] = 1;
}
/******第三部分******/
// 检查是否需要消掉行,并标记
int remove = 0; // 低 4 位用来标记方块涉及的 4 行是否有消除行为
for (y = g_CurBlock.y; y >= g_CurBlock.y - 3, y>=0; y--)
{
i = 0; //统计一行格子数量
for (x = 0; x < WIDTH; x++)
if (g_map[y][x] == 1)
i++;
if (i == WIDTH)
{
remove |= (1 << (g_CurBlock.y - y));
setfillcolor(GREEN); //绿色
setlinecolor(GREEN);
fillrectangle(0, (HEIGHT - y - 1) * PIX , WIDTH * PIX - 1, (HEIGHT - y) * PIX - 1 );
g_score += 10; //消除一行成绩加10
}
}
/******第四部分******/
if (remove) //如果产生整行消除
{
Sleep(300);
// 擦掉刚才标记的行
IMAGE img;
for (i = 0; i < 4; i++, remove >>= 1)
{
if (remove & 1)
{
for (y = g_CurBlock.y - i + 1; y < HEIGHT; y++)
for (x = 0; x < WIDTH; x++)
{
g_map[y - 1][x] = g_map[y][x];
g_map[y][x] = 0;
}
getimage(&img, 0, 0, WIDTH * PIX, (HEIGHT - (g_CurBlock.y - i + 1)) * PIX);
putimage(0, PIX, &img);
}
}
}
//产生新方块
NewBlock();
}分四部分来说明下沉函数
第一部分:分两种情况,当你下降到无法下移时,这一部分代码里面的循环是跳过的,当你按下空格键时,想必方块下方还有些许空间可以下落,这个时候需要第一部分的代码将方块移至底部
第二部分:这里比较简单了,将下落固定FIX的数据写入到地图g_map里面去,循环判断方块的每一个格子所代表的的数字,如果不为0,那么写入到地图中去。
第三部分:统计方块所在的四层是否有需要消除的情况,如果有,将行数记录在remove参数下,原理就一行代码,挺经典的代码
remove |= (1 << (g_CurBlock.y - y));假如16宫格第一行和第三行要消除,会执行两次上面的代码,第一次执行的时候remove 为 1,第二次执行的时候remove为101,可能101没有太大的意义,如果后面补个0呢,1010不就是13为真,24为假吗,是不是呢,仔细想想,没毛病~
第四部分:就是将要消除的行上面的画面整体下移一行,在统计的时候是左移储存信息,这里的remove>=1右移就是提取信息,然后就是获取当前窗口的图形getimage(),然后整体往下一格贴图。
开始旋转
OnRotate()
void OnRotate()
{
//获取可以旋转的 x 偏移量
int dx;
BLOCKINFO tmp = g_CurBlock;
tmp.dir = (++tmp.dir) % 4; //限制方向在0到3之间
if (CheckBlock(tmp))
{
dx = 0; Rotate(dx); return;
}
tmp.x = g_CurBlock.x - 1; if (CheckBlock(tmp)) {
dx = -1; Rotate(dx); return;
}
tmp.x = g_CurBlock.x + 1; if (CheckBlock(tmp)) {
dx = 1; Rotate(dx); return;
}
tmp.x = g_CurBlock.x - 2; if (CheckBlock(tmp)) {
dx = -2; Rotate(dx); return;
}
tmp.x = g_CurBlock.x + 2; if (CheckBlock(tmp)) {
dx = 2; Rotate(dx); return;
}
}旋转也是先判断旋转之后的图形是否合理,也就是CheckBlock()函数检查旋转之后的方块。如果只是这样判断的话会导致当方块在游戏区的最左边或者最右边时无法旋转,这个时候需要将图形适当左移或者右移再旋转。
旋转
Rotate()
OnRotate()只是判断是否可以旋转,真正的旋转在Rotate()函数里面
void Rotate(int dx)
{
// 旋转
DrawBlock(g_CurBlock, CLEAR);
g_CurBlock.dir++;
g_CurBlock.x += dx;
DrawBlock(g_CurBlock);
}这个旋转函数非常的简单,擦除原先的方块,偏移dx之后将下一个方向的方块绘制出来,简简单单~
04
元素优化
为了让游戏更为人性化,有必要添加一些特殊的功能,例如退出游戏,暂停,重新开始,我没有去实现文件读写功能,因为代码本身就很多了,500行,并且都是一些烧脑的逻辑处理,数据处理。
说实话,我写好程序,再编辑这篇文章花了挺长一段时间,不过我感觉这篇是我发过的项目中描绘的最好的之一。
关键字【俄罗斯方块】
End
作者:梦凡