功能:
1.贪吃蛇的基本游戏规则
2.有开始和结束界面
3.实现计分功能
4.游戏有无墙和有墙两种模式
5.游戏有简单和困难两种难度
6.在开始和结束时有声音提示
引脚 | 名称 | 解释 |
---|---|---|
1 | GND | 接地端 |
2 | VCC | 电源端 |
3 | D0 | 在 SPI 和 I2C 通信中为时钟管脚(SCLK) |
4 | D1 | 在 SPI 和 I2C 通信中为数据管脚(MOSI) |
5 | RES | 复位管脚,低电平有效 |
6 | DC | 数据和命令控制管脚,0写命令,1写数据 |
7 | CS | 片选信号输入端,当输入低电平,表明OLED被选中,若只有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显存(GDDRAM)中的值。第二,OLED是对页地址进行整体赋值,贪吃蛇在显示蛇,食物等方面都要求对像素点进行操作。所以在OLED上显示像素点就会有困难。比如说某一页上的数据是 (0100 0000) 。如果想让其他像素点显示,会对这一页重新赋值,比如(0010 0000)。赋值过后,先前的数据就会被覆盖。也就是说一页只能打一个点,因此为了避免这种情况,要记录之前显示过的值,必须要在单片机内部申请一块内存区域充当OLED的显示缓存区,每次打点的时候读取先前的数据进行运算后再给OLED传送数据。
博主购买的OLED附带中景园电子的部分源码,商家可能考虑到内存问题,就给打点函数删除掉了,博主花了好几天时间,查阅了相关资料,以及学长的帮助下,才完成了下面打印像素点的两个函数。
/*****************************************************************/
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);
}
/*****************************************************************/
以上代码需要注意的几个地方:
unsigned char OLED_GRAM[64][8] = {0};
在查阅相关资料发现,博主购买的单片机是STC公司的89C54RD+和12C5A60S2,其内存大小为均1280字节。OLED显示屏共128列8页,若在单片机中申请内存则是128×8×1=1024个字节。博主试过一次,IO口电平全部被拉低了…也就说申请的内存占用了IO口的寄存器。所以申请64×8的内存,节省一点,让蛇在左半屏活动就好了~
OLED_Write_GRAM()函数中有一句 if(x>=64)return;
OLED_Refresh()函数中有一句 for(i=0;i<64;i++) OLED_WR_Byte(0x00,OLED_DATA);
没有这两句,OLED的显示过程中会出现这种诡异的情况,最后一页乱码了。博主猜测是由于使用的12单片机,送数据和送命令之间切换过快导致的。
在中景园电子提供的代码中,下面两个函数用于实现汉字的打印和数字的打印,非常方便。用于打印菜单和得分。
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汉字的序号
void Print_Map(); //打印地图
void Print_Snake(); //打印蛇的坐标
void Print_Food(); //打印食物
void Print_Clear(); //清屏方法
以上是部分打印函数。这几个函数都使用了打点函数,也就是左半屏,用于显示游戏画面。博主采用的方式是使用了一个定时器0。在定时器中断内进行显存的更新,以此来控制贪食蛇运动速度。在每次打印蛇和食物坐标之前,会使用一次清屏函数,清除上一帧蛇,否则会留下长长的尾巴。以下是部分定时器0中断内的代码。
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; //重新计时
}
}
蛇类是贪吃蛇的核心算法。包括蛇身体坐标的储存,蛇的初始化,蛇的运动,蛇是否撞墙,是否吃到自己。
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.蛇的移动:蛇的移动其实很好理解,蛇头先上下左右移动,然后把前一节的坐标赋给后一节。如果是无墙模式,先把头坐标直接赋值到另一侧,再进行后一节赋给前一节。
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,表示无事发生。算法也很简单,撞墙就判断蛇头坐标是否和墙重合,吃自己函数就遍历所有身体坐标,看是否与头重合。
食物类分为蛇的坐标,食物的刷新,食物被吃的函数。
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.判断食物是否被吃:算法也很简单,先判断蛇头坐标是否和食物坐标重合。如果重合则蛇变长一节,把之前储存的蛇尾后面一节坐标赋给最新一节身体。然后重新生成一次食物。
除正常的独立按键检测以外,还要储存前一次按键的值,用于蛇每次自动移动,用于表示蛇移动的方向;其次,由于贪吃蛇特性,譬如在向上走的同时,只能向左向右拐。因此每次给方向赋值的时候要先判断一次,防止其转向冲突。
开始界面的实现首先要感谢好朋友的指点,给了我思路,虽然有些简陋,但基本功能可以实现。
代码部分较为冗长,这里只介绍一下基本思路。
首先要定义两个变量,一个是行数line,用于记录选中的行,一个是按键key,用于记录输入的按键。玩家只有上、下、确定键可以按。在按上下键的时候更改选中的行数,并重新打印小箭头;在按确定键的时候根据选中的行数去实现相应需求。开始游戏则跳出循环,更改难度和模式则更改相应变量的值,并把UI中的汉字更改掉。
这部分就比较好实现了,得分为蛇长度-1;结束界面在相应位置打印汉字,关闭定时器0停止显存刷新;蜂鸣器使用不同频率的脉冲给蜂鸣器的IO口送电就好。如果想实现音调do re mi,由于Hz是每秒钟周期的次数,而我们的计时器和延时函数是按毫秒统计,所以需要进行频率的换算,比如C4是261Hz。
1 261 = x 1000 × 2 \frac{1}{261}=\frac{x}{1000×2} 2611=1000×2x
可以得到x=7.66,也就是约每7.66毫秒电平变化一次,可以发出C4(中央C)的音调。延时函数并不会很准确,如果想精确实现可以使用定时器。
以下元件是博主自己使用到的,仅供参考。
元件名 | 数量 | 注释 |
---|---|---|
洞洞板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个 | 用于供电,下载程序 |
蜂鸣器电路采用这篇博客的接法,这里就不搬运了。区别是三极管集电极C端并联的33R电阻替换成了15Ω。(因为板子空间不是很够且没有买到33R)
博主购买的是4脚的微动按键开关。
四脚分别两两导通。不想检测可以焊接对角线,对角线必然不导通。如果想检测可以使用万用表的二极管挡,如果万用表蜂鸣器响则代表导通(短路)。独立按键的焊接非常简单,一端连接IO口一端共地即可。
部分图片和资料源自网络,如有侵权请联系删除。