项目地址:https://gitee.com/daycen/stm32-tetris/tree/master
使用Keil uVision5打开即可
一、概述
本文介绍了一个基于STM32的俄罗斯方块游戏实现例子
整体方案的硬件部分由一个最小系统、按键开关模块以及2.2寸TFTLCD屏幕组成,软件部分设计由绘图、逻辑、整合控制三大部分组成,由一个二维绘图函数绘制出游戏画面,并由碰撞判断、状态储存等机制实现游戏的正常运行。
需求:
开发一款基于STM32F103的游戏机,能够游玩经典游戏《TETRIS》
(1) 显示:通过屏幕显示游戏UI等信息供用户进行游玩;
(2) 控制:用户可通过独立按键进行操控;
(3) 用户进行游戏时会有分数记录、难度等级等提示;
(4) 游戏过程中可暂停游戏;
指标:
(1) 能够通过屏幕正常显示游戏信息;
(2) 对按键的操作能及时响应;
(3) 游戏结束时要显示玩家得分;
(4) 游戏过程可任意暂停;
总体方案:
五向按键模块
显示屏模块
STM32最小系统
二、软件与算法介绍
绘图工具(显示部分)
1、画圆函数&画点(线)函数
为实时显示图像,我们需要一个可以在任意的指定坐标画点的函数,我们称之为画点函数Gui_DrawLine()和画圆函数Gui_Circle()在这里,我们使用屏幕供应商提供的画点/园函数,它们基于Bresenham算法构建而成。本质上,Bresenham是一种让计算机实现高效的画线的一种算法。
下面我们用一张图来举例说明该算法的基本思想:
假设该线段位于第一象限内且斜率大于0小于1,设起点为(x1,y1),终点为(x2,y2).根据对称性,可推导至全象限内的线段。
第一步,画起点(x1,y1);
第二步,准备画下个点。x坐标增1,判断如果达到终点,则完成。否则,由图中可知,下个要画的点要么为当前点的右邻接点(B),要么是当前点的右上邻接点(U);
判断:(以跟直线上点M的纵坐标距离为依据选择下一个点的位置)
(1) 如果线段ax+by+c=0与x=x1+1的交点的y坐标大于M点的y坐标的话,下个点为U(x1+1,y1+1);
(2) 否则,下个点为B(x1+1,y1);
简单来说,就是判断U、B跟直线ax+by+c=0与直线x=x1+1的交点M之间的距离远近(通过两点间距离公式),选取近的一个作为下一点并画出,以此类推直到画出整条直线。
第三步,画点(U或者B);
第四步,跳回第2步;
结束。
细化的代码实现方式在此不做过多讨论,网络上已经有很多种较为成熟的代码实现方式,下面给出我们使用的供应商提供的具体实现代码:
Gui_DrawLine函数
void Gui_DrawLine(u16 x0, u16 y0,u16 x1, u16 y1,u16 Color)
{
int dx, // difference in x's
dy, // difference in y's
dx2, // dx,dy * 2
dy2,
x_inc, // amount in pixel space to move during drawing
y_inc, // amount in pixel space to move during drawing
error, // the discriminant i.e. error i.e. decision variable
index; // used for looping
Lcd_SetXY(x0,y0);
dx = x1-x0;//计算x距离
dy = y1-y0;//计算y距离
if (dx>=0)
{
x_inc = 1;
}
else
{
x_inc = -1;
dx = -dx;
}
if (dy>=0)
{
y_inc = 1;
}
else
{
y_inc = -1;
dy = -dy;
}
dx2 = dx << 1;
dy2 = dy << 1;
if (dx > dy)//x距离大于y距离,那么每个x轴上只有一个点,每个y轴上有若干个点
{//且线的点数等于x距离,以x轴递增画点
// initialize error term
error = dy2 - dx;
// draw the line
for (index=0; index <= dx; index++)//要画的点数不会超过x距离
{
//画点
Gui_DrawPoint(x0,y0,Color);
// test if error has overflowed
if (error >= 0) //是否需要增加y坐标值
{
error-=dx2;
// move to next line
y0+=y_inc;//增加y坐标值
} // end if error overflowed
// adjust the error term
error+=dy2;
// move to the next pixel
x0+=x_inc;//x坐标值每次画点后都递增1
} // end for
} // end if |slope| <= 1
else//y轴大于x轴,则每个y轴上只有一个点,x轴若干个点
{//以y轴为递增画点
// initialize error term
error = dx2 - dy;
// draw the line
for (index=0; index <= dy; index++)
{
// set the pixel
Gui_DrawPoint(x0,y0,Color);
// test if error overflowed
if (error >= 0)
{
error-=dy2;
// move to next line
x0+=x_inc;
} // end if error overflowed
// adjust the error term
error+=dx2;
// move to the next pixel
y0+=y_inc;
} // end for
} // end else |slope| > 1
}
Gui_Circle函数
void Gui_Circle(u16 X,u16 Y,u16 R,u16 fc)
{
unsigned short a,b;
int c;
a=0;
b=R;
c=3-2*R;
while (a<b)
{
Gui_DrawPoint(X+a,Y+b,fc); // 7
Gui_DrawPoint(X-a,Y+b,fc); // 6
Gui_DrawPoint(X+a,Y-b,fc); // 2
Gui_DrawPoint(X-a,Y-b,fc); // 3
Gui_DrawPoint(X+b,Y+a,fc); // 8
Gui_DrawPoint(X-b,Y+a,fc); // 5
Gui_DrawPoint(X+b,Y-a,fc); // 1
Gui_DrawPoint(X-b,Y-a,fc); // 4
if(c<0) c=c+4*a+6;
else
{
c=c+4*(a-b)+10;
b-=1;
}
a+=1;
}
if (a==b)
{
Gui_DrawPoint(X+a,Y+b,fc);
Gui_DrawPoint(X+a,Y+b,fc);
Gui_DrawPoint(X+a,Y-b,fc);
Gui_DrawPoint(X-a,Y-b,fc);
Gui_DrawPoint(X+b,Y+a,fc);
Gui_DrawPoint(X-b,Y+a,fc);
Gui_DrawPoint(X+b,Y-a,fc);
Gui_DrawPoint(X-b,Y-a,fc);
}
}
2、方块绘制相关函数
根据俄罗斯方块的游戏规则,每个方块由4个小块构成,一共有19种样式如下图
我们设置一个俄罗斯方块中的一小块大小为10*10,由此可由遍历的方法得到绘制一小块方块的Draw_realbox()函数如下:
同样的方法我们需要一个删除方块函数用于方块的消除,即将10*10区域画上白色即可。删除方块函数如下:
有了以上两个函数,我们只需要在规定的坐标处调用四次小方块绘制或删除函数
即可得到或消除一块完整的俄罗斯方块。而方块有19种,故使用switch语句进行选择需要何种方块。图形绘制函数如下(部分):
同理需要一个图形删除函数
3、游戏引擎(逻辑部分)
状态储存机制
我们使用了一个大小为23*16的二位数组来记录方块的位置,便于后续进行碰撞判断、方块消除等操作
数组类似于一个显示屏,里面为1的地方表示有小方块存在,我们只需要改变数组中的0、1即可实现对一个俄罗斯方块的保存,为此需要一个绘制和删除函数,逻辑与在LCD绘制方块类似,由Draw_a_zhuangtai()和Del_a_zhuangtai()实现
碰撞判断
有了之前定义的数组,我们可以使用求和的方式(类似前导零算法)找出碰撞的方块。因为在方块没发生碰撞之前,对该数组求和的值为60(数组边界)+方块占的值(方块缓存)。当方块发生碰撞,两个方块之间的交集会使得方块占的值变化(变小),与原值比较后可得出方块是否碰撞,碰撞则返回一个值。特别的,为了判断碰撞事件,在panduan()函数中我们还需要对方块的方向进行判断,因此panduan()函数还可在需要判断方向时调用。
物体消除
由函数xiaochu()实现,每发生一次碰撞就检测一次是否满足消除条件
其中,消除函数的“消除”功能是由调用换行函数lie_move()和删行函数Del_lie()实现的
形状控制函数
即按指令绘制出规定形状的俄罗斯方块的函数,一共有两个,change()函数用于绘制LCD上的,change_Zhuangtai()函数用于改变状态数组中的。
随机数生成
在游戏中,方块需要随机生成,所以我们需要一个随机数作为方块产生的依据,但仅用rand()函数生成的是伪随机数,所以我们使用srand()函数打乱伪随机数,同时引入ADC产生的末尾数据,以达到一个较高的随机性。
如:
srand(Get_Adc(ADC_Channel_1));
what=rand()%19+1;//what代表着不同俄罗斯方块
4、整合部分
方向控制
方块可进行左、右、下的移动,因此我们需要在接到指令后再对应坐标画出完整的俄罗斯方块并将原坐标处的图形消除,只需调用之前定义的图形调用/删除函数即可。值得注意的是,在移动图像的同时,我们也需要对状态数组中的数据进行相应的移动,为此我们需要一些整合后的函数如Down()、Left()、Right()、Del(),在此不做列举说明。
向左移动函数:
向右移动函数:
向下移动函数:
封装好上述方向移动功能函数后,我们只需调用它们即可实现相应方向的改变,调用函数如下:
再配合KEY_Scan()函数即可实现方向判断,该函数流程图如下:
分数、等级显示
关于分数、等级等函数,只需通过当前分数、等级变量选择相应数字在指定坐标绘制即可(此处仅列举display_leave()函数)
开始游戏
通过begin()函数和first()函数实现。方块的实际绘制是begin()函数完成的,此外该函数定义了方块的初始刷新位置并且对方块是否触顶进行判断以提示游戏结束。first()函数以负责清空上一次数组保存的状态和基本UI的绘制,由于我们设置的边界,UI部分在游戏过程中不会受到刷新影响,故只需要绘制一次。
暂停/恢复
当触发暂停功能时,该函数在指定区域绘制暂停提示,修改标志位game到暂停状态并且清空定时器使能位实现暂停。
恢复游戏运行是由star()函数实现的,当恢复时,该函数会把标志位game改为运行状态,并重绘部分UI。(受暂停提示界面的刷新影响必须重绘,不然显示会有缺失)流程图如下:
定时器中断函数
大约10μs中断一次作为基础下落的信号,在主程序中若i大于speed则执行下落函数
主程序
在主函数中,对各项进行初始化、获取随机数,通过switch语句对获取到的相应键值进行处理。