【stm32】贪吃蛇

系列文章目录

错误总结(没有什么各位有用的东西)跳转
关于嵌入式3/4作品展示(比较具体的描述了开机页面,灯控制页面的实现)跳转
心率和血压的测量稍微解释一下,有一个单个电源控制两个模块,两个模块相互影响怎么解决的一个问题。跳转

作品展示链接(B站平台内播放)

视频跳转
视频地址:

https://www.bilibili.com/video/BV1u44y1S7h5/?spm_id_from=333.999.0.0&vd_source=80dd998adf6419478f40a8a950edd07e


前言

关于用单片机实现贪吃蛇,首先的第一个难点就是在屏幕上点的绘画。换句话来说就是对屏幕的应用得到达一定的水平。
第二个难点就是关于链表的理解。我使用C语言的方式写的,但是我熟悉链表这个词是在C++的容器里面。
仅此而已。所以说是一个比较初级的项目了。

源码在本文最下面。不想看我哔哔赖赖的直接怼代码去

一、项目前的需要攻破的难点

(1)、OLED屏幕的显示

 对OLED的屏幕理解,就我目前的水平分一下理解等级
按数字越高理解越深的规则来说。1就是能够在OLED屏幕上显示英语。2是能够在OLED屏幕上显示汉字。3是能够精切的指导怎么擦除或者写入屏幕某一个位置的方法。4就是能够随意描点。
 我在做这个的时候我在3这个理解层面。我在用3的理解层面写4层面的东西的时候出现了一个问题。在描绘同一页同一列的另一个点的时候,也就是说,第一步描绘同一列的第一个点,然后我还想点亮同一列的第二个点的时候,第一个点被我擦除了。遇到的问题再转换一个角度说就是,我还得提前知道屏幕里面的这一列的哪个灯亮。也就是说,在我想点亮这个列的别的灯的时候,我重新写入灯,我得把这一列之前亮的灯也带上,因为OLED屏幕给我们写入或者说控制OLED屏幕的方式是一列8位。这件事情我得怎么做到呢。
 我借鉴到一个方法,就是在单片机里面直接有一张OLED屏幕的图。在想修改OLED屏幕的时候,把这个单片机里面的图改变,然后再真正的传进OLED。这个图的能力一定是需要一位一位的写入的,这样点亮一个灯的时候,就不会影响别的灯。这个图的实现方法是C语言里面定义一个和屏幕像素点一致的数组。
 我的代码用的是数组是最简单易懂的map[128][64]。当然代码只有更好,思路只有更好。我了解到的其实可以用一维数组的办法,map[128*64],这样应该是更省空间的,这方面我不理解。甚至其实我调用的OLED库里面的那个定义也很巧妙的用map[128][8],他还是八位八位进去的,那怎么不打扰同一列的灯呢,用一个或。再换个说法,或不就是不打扰别人写入我这位吗,在OLED屏幕里面我用不了或,我在代码里面用或,最后在录屏幕上去不就行了吗。这段我想说的大概就是,数组的定义是可以很巧妙并且可以节省空间的。这样的定义map[128]64[]还是世界上最苯的定义方法,但是也是最直观的。

关于代码的实现:

uint8_t OLED_GRAM[144][8];
void OLED_DrawPoint(uint8_t x,uint8_t y)
{
	uint8_t i,m,n;
	i=y/8;
	m=y%8;
	n=1<<m;
	OLED_GRAM[x][i]|=n;
}

//清除一个点
//x:0~127
//y:0~63
void OLED_ClearPoint(uint8_t x,uint8_t y)
{
	uint8_t i,m,n;
	i=y/8;
	m=y%8;
	n=1<<m;
	OLED_GRAM[x][i]=~OLED_GRAM[x][i];
	OLED_GRAM[x][i]|=n;
	OLED_GRAM[x][i]=~OLED_GRAM[x][i];
}
//更新显存到OLED	
void OLED_Refresh(void)
{
	uint8_t i,n;
	for(i=0;i<8;i++)
	{
	   OLED_writeByte(0xb0+i,OLED_CMD); //设置行起始地址
	   OLED_writeByte(0x00,OLED_CMD);   //设置低列起始地址
	   OLED_writeByte(0x10,OLED_CMD);   //设置高列起始地址
	   for(n=0;n<128;n++)
		 OLED_writeByte(OLED_GRAM[n][i],OLED_DAT);
  }
}

这里我展现的是我的OLED库的一些函数,贪吃蛇的函数实现方法我用更加浅显易懂的map[128][64]。每个元素我认为是布尔,只有1和0。

(1)、关于链表的相关知识

 【1】.链表储存数据的形式

在这里插入图片描述
 链表存储数据的方式先存自己的数据,再存前一个链表的数据,后一个链表的数据。虽然我觉得很多人应该都明白,但是我这里还是稍微描述一下为什么需要这样存。
 为了能够让数据的插入和删除比数据这种容器简单,所以创作了链表这样的容器(关于链表的知识我是再C++中学到的)。用上图的构造创造出的数据存储方式,在添加数据的时候,可以通过改变插入数据位置的前后的指向,便完成数据的插入。这样做可以既不耗费同一块内存存储和一大堆数据,又简单的改变需要改变的少量值便可以完成,不需要遍历数组,重新写入整串数组就是为了插入一个数据。能理解的本来就理解,不理解的建议先到别的地方了解一下先。(特别说明尾部的后面一个链表的地址一般为空。)
创建链表的代码:

typedef struct snakeNode
{
	uint8_t x;
	uint8_t y;
	struct snakeNode *next,*prev;
}SNAKE_T,*SNAKELIST;

  【2】.双向链表对这个工程的意义

 
 单向链表就是一个链表只指向后面的一个链表,而这里创建的双向链表需要在头部寻找前面的尾部。就是头需要又尾部信息的地址。为什么这样做呢主要有两大意义。

  1.方便找到尾部
  在这里我想先讲一个事情。创建链表的时候,一开始就是创建头,然后创建第二个链表,根据关系规定好第二个链表的内容。那么我们调用链表的时候,和数组是一样的,是需要一个地址去让我们找到这个数组。所以一开始我们就得记录一个全局变量的头,然后根据这个头把链表加长。也不知道这么绕一下能不能绕明白,绕不明白就记着。关于我们调用链表的话,我们只能通过头来找其他的数据。因为这样一个特性,所以才会有接下来的问题。   如果是单向链表,从头部找到尾部就需要一个想数组一样的轮询,一个一个的问,这样的效率一定不高。就像,我要你姐姐闺蜜的微信,我问你要,你还得问你姐姐,你姐姐还得问你闺蜜同不同意。很麻烦吧,等的心急如焚了。好嘞,皮一下,收回来。为了让系统的整个效率提高,或者说为了防止链表过长的时候卡死系统。所以我们让这个链表是双向的,让尾巴更容易找到。通过让记录的数据稍微庞大一个字节,来让系统的运行更加顺畅。   为什么就为了一个尾部这么大费周章,这样的操作应该就找到尾部的效率提高罢了,链表多起来,一个字节也是多啊。?  这个和接下来的第2点有很大的关系,我就目前先解释一下这个问题。为了方便清理尾部。再蛇移动的时候,无论你让蛇怎么动,你都需要清理尾巴,因为我们实现的方式就像在一张画里描点,蛇多长我们就在128*64的格子里面画点。第一次画图蛇占第一,第二,第三个格子。第二次画图,蛇站第二,第三,第四个格子。那我们留在图里面的蛇就是第一,第二,第三,第四个格子的长度。所以需要清理尾巴。清理尾巴得先找到尾巴吧,这么大费周章的为了更好的找到尾巴现有这方面的好处。 ####   2.更加简单,高效的让蛇移动   其实蛇的移动也算是这个工程的一个突破点,但为了讲明链表这里先涉及一下。  模仿蛇的移动。有两种办法可以模仿,或者说目前我知道的只有两种办法。第一种方法,所有的链表往前挪,简答粗暴。在蛇的移动的时候,所有的链表数据变成他自己前一个的链表的数据,在这样移动的时候瞬间,头链表的位置就有两个点也就是有两个自己数据一样,但是指向不一样的链表,头根据指示往你规定的方向移动,最后清楚蛇尾在图上的位置,这样便可以做到蛇的移动。没看明白,那就记着,第一种简单粗暴,所有的链表或者说蛇身给我往前挪。  第二种办法就高明的多得多。第一种方法,抽象点说,不就是清除蛇尾在图上的印记,移动蛇身吗。整个蛇的长度在没有别的情况下,一样长吧。在外面看来不就像,把蛇尾挪到蛇头吗。希望又能让你们豁然开朗,然后连说妙妙妙。快速的找到蛇尾,放在蛇头,这样蛇就动了,蛇身是什么东西,不认识。整个系统的效率不就哗啦啦的提高了。 **其实为什么用双向链表,五个字就可以表达的比较清楚了,为了蛇尾巴。**

二、从头开始写贪吃蛇

1.描点函数开始。

 画图你得先有笔吧,当然你咬手指用血画也行,那会很难受,方法不对,没有工具会很难受的,第一个很重要的工具就是描点函数。
代码展示:
点我跳转

2.制作一个二维数组作为贪吃蛇的整个地图。

 笔有了还需要有图。如果觉得我说的抽象我这里再解释一下,这个图相当于一个你的秘书,你把想要改变的数据丢给你的秘书,让他差不多在下班之前给OLED屏幕刷新一下,这样不香嘛,省的你天天要麻烦OLED。能理解就理解,不理解但是想理解就看下我的经历或许对你有帮助。
代码显示:

uint8_t Map[128][64] = {0};  //为了更好的体验先保存最后更新BUF

以下为我的个人经历


 因为我知道画蛇是用链表,链表里面记录的数据有自己的位置,X,Y。所以我认为我自己在移动蛇,创造蛇的时候输入OLED屏幕数据不就好了嘛。我这么干了,过了一下午我直呼前辈牛逼。
 首先我得画背景嘛,背景进去非常顺利,我甚至还在吐槽为什么要饶一个大弯来画背景,我直接进去不更快,更香。然后到创建蛇,也比较顺利,再到蛇移动的时候,我就觉得逻辑也来越难受,就像有只笔不知道往哪里画一样。我尝试咬破自己的血(比喻为硬上哈),然后再一堆血水上画,我发现,也许可以,但是整个代码的运行说不定会比较奇怪。首先,说一个用屏幕的一个很奇怪的其实也不奇怪的问题。你在屏幕的左边刷一些东西,想保留左边的东西,变化屏幕右边的东西的时候,你会发现非常奇怪,你不断变化右边的东西的时候,你左边的东西会乱码。为什么会出现这样的现象,我有一套不是很占的住脚的理论。因为你不断再刷新屏幕,你一段发送010101,第二段发送010101,当通道堵塞或者通道受到影响的时候,可能你屏幕收到就是111111。按着这样的理论我尝试调慢屏幕接受的速度,有一定的作用效果,但是最后还是会出现乱码。再回来,如果我不用图画的画,就创造蛇调用屏幕,创造食物调用屏幕,背景很可能会被我弄得七零八碎。就像再血水里用笔画画,你很难保证水不流回到你画的地方。(有那么一点点类似,但是不多。)首先我想到的问题就是这个,其实我想到一个问题就是我怕如果蛇长了,会明显的让客户玩游戏的体验差到要死。如果是轮询蛇尾巴,动动蛇人就可能分辨的出来循行的卡顿,动完蛇之后,食物又要有一定的时间才会出来,吃到食物蛇又有一定时间才能加长。就是说我如果不是画一张图,最后以整张图的方式上交给OLED的画,首先效果可能会产生分裂,体验也许会出现问题。所以我最后屈服了,我弄了一个数组来说图。因为这样不但解决了上面的问题,而且逻辑还清晰的要死。


3.为地图画上背景。

 我们整个项目的逻辑是先画图,最后在把图放在OLED屏幕上,所以背景得先画在我们的图里面。把背景画完就可以直接放在OLED屏幕上先。
代码演示:

void Game_break(void)
{
	int i,j;
	//User = Create_Node();
	//地图背景填充
	for(i=0;i<128;i++)
	{
		for(j=0;j<64;j++)
		{
			if(i == 3||i==127||j==62||j==0)
			{
				Map[i][j] |= 1;//实际就是在Map BUF内写 1
			}
		}
	}
	//地图背景绘制
	 	for(i=0;i<128;i++)
	{
		for(j=0;j<63;j++)
		{
			if(Map[i][j] == 1)
			{
				OLED_DrawPoint(i,j);  //调用画点API
			}
			else if(Map[i][j] == 1)
			{
				OLED_ClearPoint(i,j);  //调用画点API
			}
		}
	}
}

4.代码是边写边改的,所以我建议先把显示部分搞定。

 先把图放到OLED屏幕的相关操作作了比较好调试。
代码显示

//更新显示
void update_display(void)
{
	uint8_t x,y;
	for(x=0;x<128;x++)
	{
		for(y=0;y<64;y++)
		{
			if(Map[x][y] == 1)
			{
				OLED_DrawPoint(x,y); //填充点
			}
			if(Map[x][y] == 0)
			{
				OLED_ClearPoint(x,y);  //清除点
			}
		}
	}
}

下面是关于把蛇一个一个描点的操作,我放在游戏运行函数里面。但是按目前的步骤建议自己也命名为一个函数,逻辑上也好理解一点
	while(n != NULL)		//寻找表尾
	{
		Map[n->x][n->y] =1; //将表中的坐标写到Map中
		n=n->next;          //下一个节点
	}

5.初始函数的配置

 5.1 初始函数的配置

 初始函数要做的事情就是画墙,创建蛇头,我们这里的创建是创建蛇头,然后创建了20个蛇身。
代码部分:

void GAME_init(void)
{
	uint8_t i;
	memset(Map,0,sizeof(Map));
	Game_break();
	
	User = Creat_Node();
	User ->prev = User;
	User ->next = NULL;
	User ->x = 30;
	User ->y = 30;
	for(i=0;i<20;i++)
	{
		Add_Node(User);
	}

memset()函数是用来让数组数据请0的,调用这个函数需要包含头文件#include “string.h”。

 5.2 ADD_Node();函数详解

 这里还封装了一个加蛇长的函数。

代码演示:

void Add_Node(SNAKELIST L)
{
	SNAKELIST n = L;
	//通过头来添加尾部
	n = n->prev;
	
	SNAKELIST NEW = Creat_Node();
	NEW->prev = n;
	NEW->next = n->next; //NULL
	NEW->x = n->x;
	NEW->y = n->y;
	n->next =NEW;
	
	L->prev = NEW;
}

具体就是加入输入链表的地址,根据蛇头找到蛇尾,加长蛇尾。
为什么叫做初始函数是因为初始函数只运行一次。把运行一次的东西都放在这里的操作叫做初始化。

6.有蛇,下一步就是蛇怎么运动。

 蛇的运动在上面也稍微提了一下,这里给个跳转链接让你们先回去看下点我跳转
 蛇的运动,第一步,先清除蛇的尾巴。
第二步,根据链表的结构改变蛇尾变成蛇头。
第三步,根据按钮的方向,改变头,其实是原来的尾巴的数据。
代码展示:

void Game_Mov(SNAKELIST L)
{
	uint8_t event = last_dir;
	SNAKELIST n,t;
	t = L;
	n = t->prev;
	Map[n->x][n->y] = 0; //清除尾巴节点
//		while(n != L)   //从尾巴向前轮询
//	{
//		//轮询修改,蛇头先忽略,让蛇身先移动
//		n->x = n->prev->x;  //n的x 赋值为 上一个节点的x
//		n->y = n->prev->y;  //n的y 赋值为 上一个节点的y
//		n = n->prev;
//	}
	n->prev->next = n->next;
	n->next = t;
	n->x = t->x;
	n->y = t->y;
	User = n;
	//这是我按键读取的方法。
	uint8_t key_snake = 0;
	key_snake = get_KEY_number();
		if(key_snake==1)
		{
			Input_event = 1;
		}
		else if(key_snake==2)
		{
			Input_event = 2;
		}
		else if(key_snake==3)
		{
			Input_event = 3;
		}
		else if(key_snake==4)
		{
			Input_event = 4;
		}
		if(Input_event != last_dir)
		{
			if(Input_event != NON)
			{
				last_dir = Input_event;
			}
		}
		switch(event)
		{
			case UP:
			n->y--; break;
			case DOWN:
			n->y++; break;
			case LEFT:
			n->x--; break;
			case RIGHT:
			n->x++; break;
		}	
}

7.你得有食物让蛇延长吧。

 关于食物的产生有两个问题,第一个,随机数怎么产生。第二个,怎么判断地图上有没有食物。第三个,不让食物产生在墙上。
 第一个问题的解决这里给出的方案是定时器,在轮询过程中,如果系统察觉到没有食物的时候,就会根据定时器的目前的CNT生成食物。这种随机数虽然也不是随机的,但是接近随机。
 第二个问题这里给出的方案是设置一个标志位。在蛇吃到食物的时候就把标志位给去掉,那么就可以让整个系统察觉到没有食物,需要产生食物。
 第三个问题,规定产生的位置就好嘛。

void GAME_NewFood(void) 
{
	uint32_t seed1;
	uint8_t x,y;
	if(food_flag == GAME_NO_FOOD)
	{
		while(1){
			seed1 = TIM9->CNT;
			//让随机值可以在屏幕打出杂点
			x=seed1%120;
			y=seed1%64;
			//墙体以外不能放置食物
			if(y > 0 && y < 63 && x > 3  && x < 126)
			{
				if(Map[x][y]==0) //判断食物的位置是否为不显示状态,不能让食物显示到蛇体或者墙体上
				{
					Map[x][y]=1;  //填充地图内该点坐标为显示
					food_x_y[0] = x;  //记录食物位置
					food_x_y[1] = y;  //记录食物位置
					food_flag = GAME_FOOD;
					break;
				}
			}
		}
	}else{
		return ;
	}
}

为什么设置循环时防止食物长在蛇身上的时候,需要下一个轮询回来才生产食物,这样对用户的体验感就没那么好,不是吃完立刻就有食物产生。
food_X_Y是记录食物的位置。(不记录食物的位置就知道吃到的是墙还是食物。)

8.吃到食物,判断系统状态。

 吃到食物是什么意思,撞到食物。和撞墙的原理很像。那么就先封装一个函数叫做状态函数,如果撞的是墙,那么就游戏结束,撞到的是食物,就加成蛇身。
代码展示:

uint8_t Game_Rules(SNAKELIST L)
{
	//判断是否撞墙
	if(L->x == 0 || L->x == 127 || L->y == 0 || L->y == 63)
	{
		return 1;
	}
	//判断是否吃到食物
	if(L->x == food_x_y[0] && L->y == food_x_y[1])
	{
		food_flag = GAME_NO_FOOD; //吃到食物以后更新食物状态为无食物
		Map[food_x_y[0]][food_x_y[1]]=0; //食物被吃掉则清理食物位置,当然蛇身一定会覆盖过蛇身,此语句有没有都行
		return 2;
	}
	return 0;
}

这个函数都是返回数,所以预想的功能还未实现,只让系统有了一个分辨能力。

9.状态函数的预想的功能实现。

void Game_Run(void)
{
	SNAKELIST p = User;  //声明一个指针,指向User
	SNAKELIST n = User;    //声明一个指针,指向User
	uint8_t ret = 0;     //声明一个uint8_t 变量,用与检测状态
	Game_Mov(User);      //贪吃蛇的移动部分
	
	while(n != NULL)		//寻找表尾
	{
		Map[n->x][n->y] =1; //将表中的坐标写到Map中
		n=n->next;          //下一个节点
	}
	update_display();
	GAME_NewFood();				//检测是否放新食物
	ret = Game_Rules(User);  //检测游戏状态
	switch(ret)
	{
		case 0: //无障碍,直接跳出
			
			break;
		case 1:	//撞墙 gameover
			printf("Game over \r\n");
			game_flag = GAME_RESET;
			OLED_clear(); //清除屏幕显示
			delete_Node();
			break;
		case 2:	//吃到食物
			Add_Node(p); //增加蛇身
			break;
	}
	
}

这件事情我放在一个叫运行游戏的函数里面。

三、最后的补充

 代码还有些状态位,是为了拓宽功能的,比如暂停,加速这些。我这里就没有提及这些标志位的设置,命名也比较好理解。代码整理版的话,我稍微整理一下,但是整理版我是没有实物,验证代码是否可行的。所以没有整理的和整理的我都给你们。

总结

 写太多了,我自己都受不了了。希望能对大家有帮助。贪吃蛇就核心来说只需要解决三个问题。第一个,蛇身的描绘,第二个蛇的移动,第三个蛇撞到了啥,整个系统该干啥。
(如有侵权,联系立删)

代码链接

链接:https://pan.baidu.com/s/1wcN4Nj1zp2BPCiOgnnFIfQ?pwd=8888
提取码:8888
–来自百度网盘超级会员V3的分享

  • 3
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

所有的努力都是为了能更好的睡觉

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值