用赤菟玩贪吃蛇
本教程将使用赤菟开发板上的LCD和五相开关两个模块实现“用赤菟玩贪吃蛇”。代码开源在了:https://gitee.com/verimaker/opench-chitu-game-demos
为了更好的介绍开发流程,我们将分两部分讲解设计过程:
- 1、LCD模块使用的相关API;五相摇杆开关的简单使用。
- 2、贪吃蛇的数据结构设计以及算法实现。
1、LCD模块以及五相摇杆开关的介绍
1.1 LCD
在赤菟开发板上有一块240*240的TFT彩色小屏幕。屏幕的控制芯片为ST7789,可以使用SPI接口或8080接口,赤菟开发板使用了8080接口。
为了能够简单的使用这块TFTLCD我们将这块LCD的配置,操作等工作封装成了一系列的API存放在了lcd.c和lcd.h中,将英文和数字的字库放在了font.h中,其中存放了三种大小的字体分别是32、24和12大小。在使用时候只需要#include "lcd.h"即可。接下来我们来了解下相关的函数以及他们的使用。
下面介绍相关常用的函数。
lcd_init(); //lcd初始化函数,需要在程序开始时执行一次,主要配置了lcd的相关端口、功能等等。
配置好后就可以在lcd上显示字符串,数字,填充色块,绘制点、线、矩形、圆等。例如绘制点lcd_draw_point(u16 x,u16 y);在坐标x,y处绘制一个点,该点的颜色是由lcd_set_color(u16 back,u16 fore);决定的,也可以使用lcd_draw_point_color(u16 x,u16 y,u16 color);一步到位,注意其中color是RGB565的格式设置的。其他的函数顾名思义还是比较容易理解的,如果不理解的可以动手试试,看看在屏幕上显示什么就一目了然了。还有一点要说明的是屏幕坐标x,y对应的屏幕实物上坐标关系。我们提供的lcd配置中屏幕的左上角为显示坐标原点。水平向右为x正方向,即x为列坐标。向下为y正方向,即y为行坐标。
例如我们想在屏幕中间显示一个像素的红点,那么只需要将lcd.c、lcd.h和font.h拷贝到新建的工程目录user中,然后在main函数添加初始话函数和绘制点函数即可。
在main函数中代码如下:

对于其他的函数都可以用这样方式来尝试显示的效果。(在视频中演示可以看到中央一个红点非常小)说明屏幕显示的像素点还是非常小的,所以后面我们在设计蛇的时候需要使用多个像素点来显示蛇的结构。
1.2 五相开关
我们在玩游戏时候还需要有个控制器来控制蛇的运动轨迹,赤菟开发板上配置一个五相开关,可以输入上下左右和按下五个动作。查找赤菟开发板的端口对应表我们可以直到,这五个方向分别接在了307的五个IO口上。
我们检测按键的方法可以使用按键中断也可以使用查询io口状态,我们这里使用查询io口状态方法来检查按键情况,并设计一个函数将查询到的按键值返回出来。
在设计函数之前我们需要先将相关的io设置成输入并且打开相关的外设时钟。
void GPIO_INIT(){
GPIO_InitTypeDef GPIO_InitTypdefStruct;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA|RCC_APB2Periph_GPIOD|RCC_APB2Periph_GPIOE,ENABLE);
GPIO_InitTypdefStruct.GPIO_Pin = GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3|GPIO_Pin_4|GPIO_Pin_5;
GPIO_InitTypdefStruct.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitTypdefStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOE, &GPIO_InitTypdefStruct);
GPIO_InitTypdefStruct.GPIO_Pin = GPIO_Pin_0;
GPIO_InitTypdefStruct.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitTypdefStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitTypdefStruct);
GPIO_InitTypdefStruct.GPIO_Pin = GPIO_Pin_6|GPIO_Pin_13;
GPIO_InitTypdefStruct.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitTypdefStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOD, &GPIO_InitTypdefStruct);
}
这里我们将两个按键也一并初始化了。
下面就是按键的查询函数设计
#define up 1
#define down 2
#define left 3
#define right 4
#define sel 5
#define sw1 6
#define sw2 7
/*******************************************************************************
* Function Name : Basic_Key_Handle
* Description : Basic Key Handle
* Input : None
* Return : 0 = no key press
* key = key press down value
*******************************************************************************/
uint8_t Basic_Key_Handle( void )
{
uint8_t keyval = 0;
if( ! GPIO_ReadInputDataBit( GPIOE, GPIO_Pin_4 ) )
{
Delay_Ms(20); // 延时去抖
if( ! GPIO_ReadInputDataBit( GPIOE, GPIO_Pin_4 ) )
{
keyval = sw1;
}
}
else {
if( ! GPIO_ReadInputDataBit( GPIOE, GPIO_Pin_5 ) )
{
Delay_Ms(20);
if( ! GPIO_ReadInputDataBit( GPIOE, GPIO_Pin_5 ) )
{
keyval = sw2;
}
}
else {
if( ! GPIO_ReadInputDataBit( GPIOE, GPIO_Pin_1 ) )
{
Delay_Ms(20);
if( ! GPIO_ReadInputDataBit( GPIOE, GPIO_Pin_1 ) )
{
keyval = up;
}
}
else {
if( ! GPIO_ReadInputDataBit( GPIOE, GPIO_Pin_2 ) )
{
Delay_Ms(20);
if( ! GPIO_ReadInputDataBit( GPIOE, GPIO_Pin_2 ) )
{
keyval = down;
}
}
else {
if( ! GPIO_ReadInputDataBit( GPIOE, GPIO_Pin_3 ) )
{
Delay_Ms(20);
if( ! GPIO_ReadInputDataBit( GPIOE, GPIO_Pin_3 ) )
{
keyval = right;
}
}
else {
if( ! GPIO_ReadInputDataBit( GPIOD, GPIO_Pin_6 ) )
{
Delay_Ms(20);
if( ! GPIO_ReadInputDataBit( GPIOD, GPIO_Pin_6 ) )
{
keyval = left;
}
}
else {
if( ! GPIO_ReadInputDataBit( GPIOD, GPIO_Pin_13 ) )
{
Delay_Ms(20);
if( ! GPIO_ReadInputDataBit( GPIOD, GPIO_Pin_13 ) )
{
keyval = sel;
}
}
}
}
}
}
}
}
return keyval;
}
该函数查询当前按下的按键的值,将按键对应的值返回,没有按键按下就返回0。
2、贪吃蛇的数据结构设计以及算法实现
2.1 蛇的设计
我们想要设计贪吃蛇游戏,首先按照游戏规则,我们会有一条可以控制的蛇和一个或多个随机出现的食物,蛇在我们的控制下可以上下左右移动,当蛇头经过食物后即蛇吞下食物后自己会长大一节身体。那么这看起来还是比较复杂的事情,如果再加上一些规则或者玩法就会更加的复杂但是娱乐性将会提升,例如可以在屏幕上设计障碍;设计不同食物吞噬后有不同效果等等。好吧,我们还是从简单的开始吧,慢慢在扩充吧。那么复杂功能我们先把流程整理下,这样有助于我们的设计,贪吃蛇的流程图如下:

那么蛇如何描述呢?蛇有蛇头、蛇身、蛇尾三段结构,在LCD上显示一条蛇其实只要知道它的每段的中心点坐标就可以了,这里为了简化我们可以使用一个8*8像素点的方框来表示蛇的一个部分,那么在LCD上只需要填充一个方形区域就可以了,例如想要在屏幕中央(中心坐标即为120,120)显示一个蛇头(使用红色方块表示),则只需要使用lcd_fill(120-4,120-4,120+4,120+4,RED);
蛇身蛇尾可以使用不同的颜色填充来区分,连接部分可以空两行来显示。这样我们可以知道,蛇的每个部分其实只需要存放它的中心坐标即可,那么如何来存放这条蛇呢?即用什么样的数据结构来存放它。由于蛇的长度随着蛇吃掉食物会越来越长,如果使用数组来存放,数组的大小没法确定,因为蛇的长度是不确定的,如果开始就定义数组很大对于内存是会照成浪费的。正确的方法是使用结构体来存放蛇的坐标,使用链表将蛇的每个部分链接起来。蛇的结构体定义如下:
typedef struct Snakes
{
int x;
int y;
struct Snakes *next;
}snake;
snake *head; //定义蛇头指针
然后将蛇头结构里面的指针指向蛇身的结构,蛇身里面的指针指向蛇尾结构,这样就形成了非常直观的蛇结构:

其中蛇尾里的指针指向NULL,这也是蛇尾的标志。
那么我们就可以来创建出一条蛇了,初始化这条蛇在一个固定位置。例如设置蛇头中心坐标为(18,18),那么蛇身、蛇尾坐标应该是多少呢?由于蛇是一个8*8的方形,关节占两个像素点,所以蛇头和蛇身间距应为10个像素点,蛇身和蛇尾同理。所以如果想让这条蛇初始化时候是竖着的,那么蛇身中心坐标为(18,28),蛇尾坐标为(18,38)。创建一条蛇的代码如下:
void creatsnake(){
snake *body,*tail;
head = (snake *)malloc(sizeof(snake)); //让蛇头指向一个存储空间
body=(snake *)malloc(sizeof(snake));//蛇身指针指向一个存储空间
tail=(snake *)malloc(sizeof(snake));//蛇尾指针指向一个存储空间
head->x=18; //初始化蛇的初始坐标
head->y=18;
body->x=18; //蛇身坐标
body->y=28;
tail->x=18; //蛇尾坐标
tail->y=38;
head->next=body; //蛇头指向蛇身
body->next=tail; //蛇身指向蛇尾
tail->next=NULL; //蛇尾指向NULL
}
这样蛇的数据在内存中就已经初始化好了。但是在LCD上却什么都看不见,因为没有将蛇在屏幕上打印出来。打印蛇的函数如下:
void printsnake(){
snake *p=head;
uint8_t flag =0;
while(p->next!=NULL){
if(flag == 0){
//head
lcd_fill((p->x)-4,(p->y)-4,(p->x)+4,(p->y)+4,RED);
flag++;
}
else {
//body
lcd_fill((p->x)-4,(p->y)-4,(p->x)+4,(p->y)+4,BLUE);
}
p=p->next;
}
//tail
lcd_fill((p->x)-4,(p->y)-4,(p->x)+4,(p->y)+4,GREEN);
}
在这个函数里先打印蛇头,然后遍历整个蛇身打印蛇身,最后打印蛇尾,这样无论蛇多长都可以打印出来。
那么我们看下效果:
蛇头红色,蛇身蓝色,蛇尾绿色。
2.2 用五向开关控制蛇的移动
蛇如何移动呢?这是本项目设计里面最关键的算法。如何简单的让蛇移动起来,在数据上蛇头的中心坐标会在移动的方向上x或者y加上或将去10。蛇身蛇尾也是如此。如果这样蛇头,蛇身和蛇尾都要进行计算,如果在蛇身短的时候还体现不出计算量,如果蛇身很长呢?所以这里最小的运算量才是最好的算法。我们提供一种简单的算法,首先根据按键获得的值知道蛇的移动方向,在蛇的移动方向上新建一个蛇头,并把这个蛇头连接到旧蛇头上如图:

然后我们通过遍历蛇来到蛇尾,并将蛇尾去掉,将原来蛇身最后一节变成蛇尾。过程如下:
去掉中间的过程,得到了下面的图:
从图中可以看出,蛇前进了一格,而只需要将蛇头的坐标按运动方向上进行一次增加而蛇身和蛇尾的坐标不用计算。假设我们让蛇固定向右移动,为了控制蛇的移动速度,在每次移动之间加入一个延时。代码如下:
void movesnake(){
int x=head->x;
int y=head->y;
snake *p,*q;
x+=10; //固定向右移动
p=q=head;
snake *newhead=(snake *)malloc(sizeof(snake)); //创建新蛇头节点
newhead->x=x;
newhead->y=y;
newhead->next = head; //将新蛇头接到老蛇头上
head=newhead;
lcd_fill((head->x)-4,(head->y)-4,(head->x)+4,(head->y)+4,RED); //打印新蛇头
lcd_fill((q->x)-4,(q->y)-4,(q->x)+4,(q->y)+4,BLUE); //打印原来蛇头变成蛇身体
while(p->next!=NULL){
q=p;
p=p->next;
} //移动到蛇尾。
lcd_fill((p->x)-4,(p->y)-4,(p->x)+4,(p->y)+4,WHITE);//删除蛇尾,并插除蛇尾。
q->next=NULL;
lcd_fill((q->x)-4,(q->y)-4,(q->x)+4,(q->y)+4,GREEN);//打印新蛇尾。
free(p);
Delay_Ms(250); //延时
}
在main函数中的while(1)中添加movesnake(),下载到赤菟开发板,我们看看现象:

接下来让蛇按照我们按键的指令进行移动,按键有上下左右四个方向,但是要注意的是蛇在移动过程中其实只有三个方向前、左或右,而没有后退。所以在按键判断上要进行控制的限制。除此之外记得将之前按键检索的函数添加进main.c。蛇移动代码增加为以下形式:
uint8_t dir = 0; //全局变量用来存放当前蛇的运动方向
uint8_t key = 0;
void movesnake(){
int x=head->x;
int y=head->y;
uint8_t getkey =0;
snake *p,*q;
getkey = Basic_Key_Handle(); //读取按键值
if (getkey) {
key=getkey; //如果有按键按下更新按键值,如果没有新键按下保持原来的运动
}
switch (key)
{
case up:
if (dir!=down) {// 向下运动时不可以向上直接掉头,以下一样
y-=10;
dir = up;
}
break;
case down:
if (dir!=up) {
y+=10;
dir = down;
}
break;
case left:
if (dir!=right) {
x-=10;
dir = left;
}
break;
case right:
if (dir!=left) {
x+=10;
dir = right;
}
break;
default:
break;
}
if(x!=head->x||y!=head->y){ //没有键按下时保持原来的运动
p=q=head;
snake *newhead=(snake *)malloc(sizeof(snake)); //创建新蛇头节点
newhead->x=x;
newhead->y=y;
newhead->next = head; //将新蛇头接到老蛇头上
head=newhead;
lcd_fill((head->x)-4,(head->y)-4,(head->x)+4,(head->y)+4,RED); //打印新蛇头
lcd_fill((q->x)-4,(q->y)-4,(q->x)+4,(q->y)+4,BLUE); //打印原来蛇头变成蛇身体
while(p->next!=NULL){
q=p;
p=p->next;
} //移动到蛇尾。
lcd_fill((p->x)-4,(p->y)-4,(p->x)+4,(p->y)+4,WHITE);//删除蛇尾,并插除蛇尾。
q->next=NULL;
lcd_fill((q->x)-4,(q->y)-4,(q->x)+4,(q->y)+4,GREEN);//打印新蛇尾。
free(p);
Delay_Ms(250); //延时
}
}
这样就可以用五相按键控制蛇的运动了。
2.3 喂蛇
终于到了蛇可以吃东西环节了,那么首先需要有食物,在地图上出现的食物其实就是一个坐标(食物的中心坐标),那么如何设计食物的数据结构呢?其实很简单只需要一个结构体来存放食物的中心坐标。
struct Foods
{
int x;
int y;
}food;
这里我们设计食物也为8*8像素的方块(方便后面的碰撞检测设计)。然后食物出现的坐标应该是随机的,这样才符合游戏趣味。如果想要坐标的x,y为随机数我们可以利用CH32V307内部的一个随机数发生器的外设(硬件随机数发生器)。参看CH32V307数据手册第29章随机数发生器(RNG)章节,通过简单的配置就可以得到一个随机数。首先需要添加头文件#include "ch32v30x_rng.h"
,然后通过打开随机数外设时钟,然后使能就可以得到随机数。在main函数里添加初始化代码如下
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_RNG, ENABLE);
RNG_Cmd(ENABLE);
然后在需要随机数的地方通过函数RNG_GetRandomNumber()
获得。
下面我们需要设计下食物随机出现的范围,总的来说有如下几点要求:1、随机的坐标必须在屏幕显示范围内,即0-239。2、食物不能出现在蛇的身体任何部位上。3、食物必须出现在蛇能经过的道路上。前两点很容易理解。对于第3点的意思是如果需要设计蛇头和食物碰撞判定的话可以使用多种方法,其中一种最简单的方法是中心坐标碰撞,这样的话由于蛇的移动是加或减10运动,即一次变化10,如果食物中心正好出现在两条路的中间,那么蛇就尴尬了,永远吃不到这个食物了。如果我们使用边沿碰撞判定可以解决这个问题,但是算法上就会复杂,我们这里讲解易懂简单的中心碰撞为例。那么我们只需要随机产生的随机数在0-239之内,然后为了不让蛇超出屏幕,所以我们先需要设计一个地图的范围,我们这里将第0-3行,0-3列设计为地图边框,同样的最左和最下也空出4行4列为地图边框。这样我们的蛇的行走路径就是8,18,28......这样的值,最大到228。这样问题就简单了,只需要在0-22之间的随机数,然后乘以10加上8即可,行列一样。
((RNG_GetRandomNumber())%(23))*10+8;
接下来编写初始化时创建食物函数,并在其中实现1和2的算法要求。
void creatfood(){
int flag=0; //创建食物成功标志,1为成功
while(!flag){ //直到食物创建成功
flag=1;
food.x=((RNG_GetRandomNumber()%23)*10+8);//产生随机坐标,并且在蛇的行走路径上
food.y=((RNG_GetRandomNumber()%23)*10+8);
snake* judge = head;
while (1) //遍历排除蛇身查找产生的食物在不在蛇身上
{
if (food.x == judge->x && food.y == judge->y)
{
flag = 0; //如果在蛇身上表示创建失败
}
if (judge->next == NULL) break; //如果到了蛇尾,退出循环
judge = judge->next;
}
}
lcd_fill(food.x-4, food.y-4, food.x+4, food.y+4, RED); //创建成功,打印食物到屏幕上
}
在main函数里面加入创建食物初始化creatfood();下载到赤菟开发板,每次重启都会看到食物出现在不同的位置上。
蛇吃食物
在之前描述中设计了食物,并且这个食物中心在蛇的中心移动路径上,当蛇头坐标和食物坐标相同时表示蛇吃到这个食物了,蛇吃完食物后食物会消失,并重新生成1个新的食物,此外蛇的身体增加1节。按照这样的要求设计一个算法如下:
void eatfood(){
if (head->x == food.x && head->y == food.y) //如果蛇头和食物坐标碰撞
{
creatfood();//创建一个新的食物
snake* _new = (snake*)malloc(sizeof(snake)); //创建一个新的蛇身体
snake* p;
p = head;
while (1) //遍历到蛇尾
{
if (p->next == NULL) break;
p = p->next;
}
p->next = _new; //将新的蛇身添加到蛇尾,连接上去
_new->next = NULL;
}
}
然后将这个函数放在main函数里面的while中。
while(1){
movesnake();
eatfood();
}
下载代码到赤菟开发板,就可以实现蛇吃食物了。
2.4 游戏判定
到目前为止,一条贪吃蛇就算完成了,真正意义上的贪吃蛇,它无论怎么走,怎么吃游戏都不会GAME OVER!因为我们对游戏的成败还没有判定,main函数里面的while判定是1,一直循环下去。现在我们要加入游戏成败的判定,将while中的判定1替换掉,使之成为完整的游戏。
首先我们设计几个游戏判定规则:1、蛇不能超出地图范围;2、蛇不能咬到自己的身体或者尾巴。只要其中一个发生游戏结束。
我们增加一个判定函数来实现以上两个规则
int judge(){
if(head->x<=3||head->y<=3||head->x>=239-4||head->y>=239-4){//判定有没有超出地图
return 0;
}
snake *p=head->next;
while(p->next!=NULL){
if(head->x==p->x&&head->y==p->y){//检查是不是咬到蛇身
return 0;
}
p=p->next;
}
if(head->x==p->x&&head->y==p->y){//检查是不是咬到蛇尾
return 0;
}
return 1;
}
将main中的while替换为:
while(judge()){
movesnake();
eatfood();
}
最后如果游戏结束,送上经典的GAME OVER!
在while结束后面添加:
while(judge()){
movesnake();
eatfood();
}
lcd_show_string(50, 120, 32, "GAME OVER");
这样我们的贪吃蛇简易版游戏就完成了。
One More Thing — 项目拓展
如果为了增加游戏的趣味性可以通过在相应的模块中增加些功能,例如:在eatfood函数中可以增加分数的统计,还可以根据分数的增加增加游戏难度,例如每次出现食物变多,增加障碍物等。还可以利用赤菟的网口扩展板实现赤菟开发板网络连接实现网络对战的贪吃蛇。