一、成果展示
功能:
1.贪吃蛇的基本游戏规则
2.有开始和结束界面
3.实现计分功能
4.游戏有无墙和有墙两种模式
5.游戏有简单和困难两种难度
6.在开始和结束时有声音提示
源码链接,提取码:o93u
二、软件部分
1.OLED模块与SSD1306使用
- OLED引脚图
OLED的引脚与SSD1306和单片机的通信方式有关。常见的有SPI,I2C串行通信和并行通信等。博主购买的是7针的OLED模块,引脚详情如下。
引脚 | 名称 | 解释 |
---|---|---|
1 | GND | 接地端 |
2 | VCC | 电源端 |
3 | D0 | 在 SPI 和 I2C 通信中为时钟管脚(SCLK) |
4 | D1 | 在 SPI 和 I2C 通信中为数据管脚(MOSI) |
5 | RES | 复位管脚,低电平有效 |
6 | DC | 数据和命令控制管脚,0写命令,1写数据 |
7 | CS | 片选信号输入端,当输入低电平,表明OLED被选中,若只有OLED通信可直接接地 |
- OLED指令
与LCD1602类似,DC管脚置0之后,向OLED中写入命令,下表为部分指令。
指令 | 解释 |
---|---|
00H-0FH | 页地址模式下设置列起始地址低位 |
10H-1FH | 页地址模式下设置列起始地址高位 |
20H | 设置寻址模式,00H/01H/02H为水平/垂直/页地址模式 |
26H/27H | 设置水平滚动的起始页,终止页和滚动速度 |
29H/2AH | 设置垂直和水平滚动的起始页,终止页,滚动速度,垂直滚动偏移 |
2EH | 禁用滚动,调用后RAM数据需要重写 |
2FH | 启用滚动,在26H/27H/29H/2AH设置好后调用 |
40H-7FH | 设置屏幕(地址)起始行,取值范围为[0,63],一般从头显示 |
81H | 设置对比度,共有256级对比度 |
A0H/A1H | 设置段重映射,A0H左右反置,A1H正常 |
A3H | 设置滚动垂直区 |
A4H/A5H | 设置全屏点亮,A5H无视GDDRAM点亮全屏,A4H正常 |
A6H/A7H | 设置反转显示,A7H反转(0表示点亮),A6H正常(1表示点亮) |
A8H | 设置复用率,默认63 |
AEH/AFH | 设置屏幕开启/关闭,AEH关闭屏幕,AFH开启屏幕 |
B0H-B7H | 页地址模式下设置目标显示位置页起始地址 |
C0H/C8H | 设置列输出扫描方向,C0H左右反置,C8H正常 |
D3H | 设置显示偏移 |
D5H | 设置显示时钟震荡频率 |
D9H | 设置预充电周期 |
DAH | 设置列引脚硬件配置 |
DBH | 设置VCOMH反压值 |
E3H | 空指令,不产生作用 |
- OLED与贪吃蛇
第一,由于OLED模块不能发送数据,所以不能读取OLED显存(GDDRAM)中的值。第二,OLED是对页地址进行整体赋值,贪吃蛇在显示蛇,食物等方面都要求对像素点进行操作。所以在OLED上显示像素点就会有困难。比如说某一页上的数据是 (0100 0000) 。如果想让其他像素点显示,会对这一页重新赋值,比如(0010 0000)。赋值过后,先前的数据就会被覆盖。也就是说一页只能打一个点,因此为了避免这种情况,要记录之前显示过的值,必须要在单片机内部申请一块内存区域充当OLED的显示缓存区,每次打点的时候读取先前的数据进行运算后再给OLED传送数据。
- 在OLED上打印像素点
博主购买的OLED附带中景园电子的部分源码,商家可能考虑到内存问题,就给打点函数删除掉了,博主花了好几天时间,查阅了相关资料,以及学长的帮助下,才完成了下面打印像素点的两个函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | /*****************************************************************/ unsigned char OLED_GRAM[64][8] = {0}; //申请一个二维数组作为显存 /*****************************************************************/ //显示地图上的点 //我设置的是y轴向下的坐标系 void OLED_Write_GRAM(u8 x,u8 y,bit value) { u8 OLED_page = y/8; u8 OLED_page_value = 1 << y%8; if(x>=64) return; if(value)OLED_GRAM[x][OLED_page]|=OLED_page_value; else OLED_GRAM[x][OLED_page]&=~OLED_page_value; } /*****************************************************************/ //向OLED传输显存数据 //更新显存到OLED void OLED_Refresh(void) { unsigned char i,n; for(i=0;i<8;i++) { OLED_WR_Byte(0xb0+i,OLED_CMD); //设置行起始地址 OLED_WR_Byte(0x00,OLED_CMD); //设置低列起始地址 OLED_WR_Byte(0x10,OLED_CMD); //设置高列起始地址 for(n=0;n<64;n++) { OLED_WR_Byte(OLED_GRAM[n][i],OLED_DATA); //delay_ms(1); } } for(i=0;i<64;i++) OLED_WR_Byte(0x00,OLED_DATA); } /*****************************************************************/ |
以上代码需要注意的几个地方:
1 | unsigned char OLED_GRAM[64][8] = {0}; |
在查阅相关资料发现,博主购买的单片机是STC公司的89C54RD+和12C5A60S2,其内存大小为均1280字节。OLED显示屏共128列8页,若在单片机中申请内存则是128×8×1=1024个字节。博主试过一次,IO口电平全部被拉低了…也就说申请的内存占用了IO口的寄存器。所以申请64×8的内存,节省一点,让蛇在左半屏活动就好了~
1 2 | OLED_Write_GRAM()函数中有一句 if(x>=64)return; OLED_Refresh()函数中有一句 for(i=0;i<64;i++) OLED_WR_Byte(0x00,OLED_DATA); |
没有这两句,OLED的显示过程中会出现这种诡异的情况,最后一页乱码了。博主猜测是由于使用的12单片机,送数据和送命令之间切换过快导致的。
- 在OLED上打印汉字和数字
在中景园电子提供的代码中,下面两个函数用于实现汉字的打印和数字的打印,非常方便。用于打印菜单和得分。
1 2 | void OLED_ShowNum(u8 x,u8 y,u16 num,u8 len,u8 size2);//x,y坐标,num打印的数字,len数字的位数,size字体大小 void OLED_ShowCHinese(u8 x,u8 y,u8 no);//x,y坐标,no汉字的序号 |
2.贪吃蛇的基本算法
- 打印
1 2 3 4 | void Print_Map(); //打印地图 void Print_Snake(); //打印蛇的坐标 void Print_Food(); //打印食物 void Print_Clear(); //清屏方法 |
以上是部分打印函数。这几个函数都使用了打点函数,也就是左半屏,用于显示游戏画面。博主采用的方式是使用了一个定时器0。在定时器中断内进行显存的更新,以此来控制贪食蛇运动速度。在每次打印蛇和食物坐标之前,会使用一次清屏函数,清除上一帧蛇,否则会留下长长的尾巴。以下是部分定时器0中断内的代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | void time0() interrupt 1 { TH0 = (65535-10000)/256; //10ms初值 TL0 = (65535-10000)%256; time_count++; //计时 if(time_count >= mode) //mode为难度 { Food_Is_Eaten(); //判断食物是否被吃了 Print_Clear(); //清屏函数 Print_Snake(); //打印蛇的坐标 Print_Food(); //打印食物 OLED_Refresh(); //把显存中的值赋给OLED time_count = 0; //重新计时 } } |
- 蛇
蛇类是贪吃蛇的核心算法。包括蛇身体坐标的储存,蛇的初始化,蛇的运动,蛇是否撞墙,是否吃到自己。
1 2 3 4 5 6 7 8 9 10 11 | typedef struct Snake//蛇结构体,用于储存蛇的身体和食物的坐标 { u8 x; //x为横坐标 u8 y; //y为纵坐标 }Snake; Snake snake[MAX]; //蛇身体结构体,食物,尾巴 u8 length = 1; //储存蛇的长度 void Snake_Init(); //初始化蛇 void Snake_Move(); //操控蛇移动 后一节给前一节赋值 如果无墙模式 撞墙之后要在另一侧打印 bit Snake_eatself(); //判断是否吃到自己 bit Snake_HitWall(); //判断蛇是否撞墙 |
1.蛇结构体:蛇身体坐标的储存采用了一个结构体数组,仅有x,y两个坐标的成员变量,同时也能储存食物的坐标。
2.蛇的初始化:首先要给蛇身体的结构体数组开辟一块内存空间,并用length变量储存蛇的长度。蛇头坐标为snake[0].x和snake[0].y;然后依次向后储存到第length个。C语言基础较好的朋友可能会用链表,malloc()函数去动态分配内存。在查阅相关资料发现由于51单片机内存太小,malloc()函数申请的内存很容易申请不到,返回NULL,也是因为博主学艺不精,放弃了这个想法。妥协之下,在蛇到达最大长度的时候直接退出游戏。最大长度为MAX,这里我设置的是30。(在控制台写贪吃蛇的时候博主很暴力的把max设置成地图长乘地图宽)第二,要给蛇头赋初值,博主在初始化函数中初始化了两节坐标。
3.蛇的移动:蛇的移动其实很好理解,蛇头先上下左右移动,然后把前一节的坐标赋给后一节。如果是无墙模式,先把头坐标直接赋值到另一侧,再进行后一节赋给前一节。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | void Snake_Move() //操控蛇移动 后一节给前一节赋值 { u8 i; tail.x = snake[length].x; //保留上次尾巴的坐标 tail.y = snake[length].y; //当吃到食物的时候将该坐标赋给蛇结构第length个的坐标 for(i = length;i > 0 ; i--) { snake[i].x = snake[i - 1].x; snake[i].y = snake[i - 1].y; } if(wall == 0) { if(snake[0].x == 63) //如果与右墙相撞 { snake[0].x = 1; //到最左侧 } else if(snake[0].x == 0)//如果与左墙相撞 { snake[0].x = 62; //到最右侧 } else if(snake[0].y == 0)//如果与上墙相撞 { snake[0].y = 62; //到最下侧 } else if(snake[0].y == 63)//如果与下墙相撞 { snake[0].y = 0; //到最上侧 } } } |
但如果按照上述算法,有一个很致命的问题,在头坐标赋给第二节身体的时候,第二节身体再赋值给第三节,那第三节的元素不也是第一节的吗?因此采用的是从后向前赋值。从把倒数第二节坐标赋给尾巴,倒数第三节坐标赋给倒数第二节,以此类推。最后再根据蛇的运动方向使蛇头移动。至于为什么保留尾巴的坐标,与吃食物有关,后面再解释。
4.是否撞墙,是否吃到自己
撞墙和吃到自己的函数返回值都是bit类型(0和1),类似bool类型,如果撞墙/吃到自己返回1,否则返回0,表示无事发生。算法也很简单,撞墙就判断蛇头坐标是否和墙重合,吃自己函数就遍历所有身体坐标,看是否与头重合。
- 食物
食物类分为蛇的坐标,食物的刷新,食物被吃的函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | Snake food; //食物结构,储存x,y坐标 void Food_Init() //随机生成食物 清除原先食物的坐标 写入新的坐标 { //食物生成的算法是利用定时器生成两个62范围内的数,如果与蛇重合重新生成 while(1) { u8 i; food.x = TL1 % 61+1;//加1是防止和地图边缘重合 food.y = TL0 % 61+1; for(i = 0; i < length ; i++) { if(food.x == snake[i].x && food.y == snake[i].y) continue; //如果生成的食物与蛇身体重合,进行下一次循环 } break; //如果没有和身体重合,退出循环,退出函数 } } void Food_Is_Eaten() //判断食物是否被吃了 { if(snake[0].x == food.x && snake[0].y == food.y) { length++; snake[length].x = tail.x; snake[length].y = tail.y; Food_Init(); //重新生成一次食物 Print_Score(); //更新一次分数 } } |
1.食物生成/更新:在游戏开始和食物被吃的时候,才调用这个函数,重新生成食物坐标。采用了定时器0和定时器1的TL值取模来生成x和y坐标(1-62之内的随机数)。如果和蛇身体有重合,则重新生成。
2.判断食物是否被吃:算法也很简单,先判断蛇头坐标是否和食物坐标重合。如果重合则蛇变长一节,把之前储存的蛇尾后面一节坐标赋给最新一节身体。然后重新生成一次食物。
3.按键检测
除正常的独立按键检测以外,还要储存前一次按键的值,用于蛇每次自动移动,用于表示蛇移动的方向;其次,由于贪吃蛇特性,譬如在向上走的同时,只能向左向右拐。因此每次给方向赋值的时候要先判断一次,防止其转向冲突。
4.开始界面
开始界面的实现首先要感谢好朋友的指点,给了我思路,虽然有些简陋,但基本功能可以实现。
代码部分较为冗长,这里只介绍一下基本思路。
首先要定义两个变量,一个是行数line,用于记录选中的行,一个是按键key,用于记录输入的按键。玩家只有上、下、确定键可以按。在按上下键的时候更改选中的行数,并重新打印小箭头;在按确定键的时候根据选中的行数去实现相应需求。开始游戏则跳出循环,更改难度和模式则更改相应变量的值,并把UI中的汉字更改掉。
5.得分面板,结束界面,无源蜂鸣器驱动函数
这部分就比较好实现了,得分为蛇长度-1;结束界面在相应位置打印汉字,关闭定时器0停止显存刷新;蜂鸣器使用不同频率的脉冲给蜂鸣器的IO口送电就好。如果想实现音调do re mi,由于Hz是每秒钟周期的次数,而我们的计时器和延时函数是按毫秒统计,所以需要进行频率的换算,比如C4是261Hz。
2611?=1000×2x?
可以得到x=7.66,也就是约每7.66毫秒电平变化一次,可以发出C4(中央C)的音调。延时函数并不会很准确,如果想精确实现可以使用定时器。
三、硬件部分
1.元件清单
以下元件是博主自己使用到的,仅供参考。
元件名 | 数量 | 注释 |
---|---|---|
洞洞板8*8 | 1个 | 也可以采用7×9,6×8,40pin锁紧座6.5cm左右 |
40针IC锁紧座 | 1个 | 也可以40针IC座,价格便宜但拔插困难 |
10K电阻 | 3个 | P0口使用了1个,蜂鸣器和复位电路1个,P0也可以买排阻 |
471(470Ω)电阻 | 1个 | 电源指示灯电路会用到 |
2K电阻 | 1个 | 蜂鸣器电路会用到 |
15Ω电阻 | 1个 | 蜂鸣器电路会用到 |
33μF电容 | 2个 | 晶振两端的负载电容 |
10μF电容 | 1个 | 复位电路会用到 |
104(0.1μF)电容 | 2个 | 电源的滤波电容和蜂鸣器旁路电容 |
独立按键 | 6个 | 上下左右确定和复位电路按键 |
排针、排母 | 若干 | 用于IO口和OLED的连接 |
杜邦线 | 若干 | 用于IO口和OLED的连接 |
OLED模块 | 1个 | 显示屏幕 |
STC89C54RD+ | 1个 | 单片机 |
12MHz晶振 | 1个 | 12MHz便于定时器定时 |
无源蜂鸣器 | 1个 | 发出提示音 |
三极管8550 | 1个 | 蜂鸣器电路使用,放大信号 |
CH340模块 | 1个 | 用于供电,下载程序 |
2.最小系统的焊接
- 晶振电路
采用普中开发板的接法,如图所示。
- 复位电路
采用普中开发板的接法,如图所示。
- 电源指示灯
IO口给低电平二极管发光,用于测试单片机是否正常运行。
3.蜂鸣器电路的焊接
蜂鸣器电路采用这篇博客的接法,这里就不搬运了。区别是三极管集电极C端并联的33R电阻替换成了15Ω。(因为板子空间不是很够且没有买到33R)
4.独立按键的焊接
博主购买的是4脚的微动按键开关。
四脚分别两两导通。不想检测可以焊接对角线,对角线必然不导通。如果想检测可以使用万用表的二极管挡,如果万用表蜂鸣器响则代表导通(短路)。独立按键的焊接非常简单,一端连接IO口一端共地即可。
四、错误示范汇总
1.软件部分
- 关于51单片机选择问题
博主考虑到内存大小和flash空间问题,购买了STC89C54RD+和STC12C560S2,事实证明,12单片机总是会出现一些无法理解的错误,最后程序是在89C54上完成的。而常用的52单片机8K字节flash,512字节RAM更加捉襟见肘。 - 关于IIC和SPI通信选择问题
IIC通信速度较SPI通信速度慢,因此不适合使用IIC通信。 - 关于片选CS的问题
在阅读oled.c源码时,由于只有oled与单片机通信,CS端接地就万事大吉,以为CS没什么用,就手贱删掉了CS拉低的代码,事后想想也好蠢…后来又不接CS管脚,OLED也不会亮,还是老老实实的接在IO口上吧… - 关于地图大小的选择问题
之前非常天真的把地图设置成60×60,但是在给显存赋值,让蛇穿墙的时候,生成食物坐标时会造成麻烦,遂改成64×64。 - 定时器0与定时器1中断冲突问题
定时器1用于随机生成食物,但是千万不要开定时器1的中断!!!不要手欠写ET1=1;两个中断会冲突,这个错误排查了好久。
2.硬件部分
- 洞洞板的选择问题
为了图省事在线下购买的洞洞板,他下面都是连着的!!!表示第一次见这种洞洞板~第一次焊的时候,焊好之后才发现IO口都并上了…白闻了一下午焊锡…但是合理利用的话会省很多事。
(错误示范图) - 关于31管脚的上拉电阻问题
在普中科技的单片机开发板原理图中,31管脚有接一个4.7k的上拉电阻,但STC公司的单片机内部上拉,也可以不接。
- 元件引脚焊接问题
在第一块板子焊费之后,40脚的IC紧锁座根本拿不下来。(我的2块钱!!!)出于保险,在第二块板子上,我直接把IC座卡在板子上,没有点焊锡,结果电路连接的不是很顺畅,又检查了好几天。
3.排查电路问题的方法分享
- 检查电路是否导通
万用表真的很好用。调到二极管挡,测试有没有虚焊,哪里有没有导通,如果导通万用表蜂鸣器会响。就是用这个方法测出来IC座虚焊的。 - 检查晶振是否工作
晶振正常工作的时候,程序会执行,如果晶振不工作,程序会卡在初始状态。这个时候的现象一般是单片机管脚有电压,但是程序不运行。 - 检查单片机是否正常运行
可以通过电源指示灯来判断。我写了一个很简单的闪烁的灯的程序,如果灯正常闪烁,代表单片机正常运行。
部分图片和资料源自网络,如有侵权请联系删除。