[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)

目录

  • 设计目标
    • 需求分析
  • 硬件设计
    • 点阵模块
    • 数码管模块
    • 节拍信号发生电路
    • 蜂鸣器电路
    • 串口驱动电路、按键电路及MCU最小系统
  • 软件设计
    • 重要数据结构
      • 显存
    • 驱动
      • LED点阵驱动
        • 74595驱动
        • 点阵扫描
        • 点阵刷新
      • 数码管驱动
      • 蜂鸣器驱动
      • 计时器0的复用
        • 产生周期变量
        • 产生扫描信号
      • EEPROM驱动
    • 节拍信号和随机数生成
      • 实验:随机性的大致验证
    • 节拍动作和状态机
      • 状态机:接收输入
      • 节拍动作:更新vram并判断生死
      • 得分动作
      • AddHead()函数的补充:红绿蛇身的实现方法
      • 节拍动作:刷新595缓冲数组
    • 主循环和按键检测
    • 初始化和寄存器配置
      • 重启函数
  • 成品特写
    • 正面
    • 焊接面
    • 初始画面
    • 结束时

设计目标

  1. 实现游戏逻辑
  2. 以红绿双色共阳极点阵M1588BRG和红色共阳极数码管MT03641BR作为图像输出设备,其中两块点阵输出16*8图像,4位数码管输出分数。
  3. 游戏中的食物以黄色显示,蛇身以红绿色显示,头部为红色,之后体节颜色依次交替,以突出行进感。
  4. 以无源蜂鸣器作为音效输出设备,仅在得分时发出一个短促的响声
  5. 以按键作为输入设备,分为up、down、left、right、middle五个按键,呈十字形排列。
  6. 实现最高分记录,且断电不丢失

需求分析

1.点阵和数码管的扫描需要占用MCU的一个定时器
2.游戏中的食物需要随机放置,本设计采用定时器生成随机数的方案,则需要占用另一个定时器
3.游戏节拍由外部时钟产生,需要一个外部中断
4.最高分数据的掉电保持需要使用EEPROM(51没有EEPROM存储)
5.由于外设较多,IO端口资源在本设计中比较紧张,具体分配请见后文

硬件设计

硬件设计部分相对比较固定,本部分只描述大致结构,出于布线的方便,本设计各个模块的高低位并未与MCU的IO口的高低位一一对应,在实际设计中,如果有条件的话(比如使用较大的洞洞板或使用PCB软件绘制电路),应该尽量让各个位对应起来,这样软件部分不需要显式的译码程序,可以提高代码的可读性和执行效率。

点阵模块

M1588BRG有24个引脚,每个引脚占用一个IO口不合理。为了减少使用的IO口,MCU需要通过能实现串转并的芯片将信号送到点阵。本设计中使用74595——具有三态输出能力8位移位寄存器。

原理图
两块点阵一共需要6颗74595,为了减少IO口资源的使用,本设计使用595级联的方式,理论上可以将6颗595全部级联,但是出于布线的考虑,选择将595两两级联,形成3个16bit的移位寄存器,每组595连接方式如下图。

这三组595分别控制了,左边点阵的红绿列选(阴极),右边点阵的红绿列选(阴极)以及行选(共阳极)。
各引脚定义如下

//左边点阵
sbit SER_L = P0^0;
sbit OE_L = P0^1;
sbit RCLK_L = P0^2;
sbit SRCLK_L = P0^3;
sbit SRCLR_L = P0^4;
//右边点阵
sbit SER_R = P2^0;
sbit OE_R = P2^1;
sbit RCLK_R = P2^2;
sbit SRCLK_R = P2^3;
sbit SRCLR_R = P2^4;
//共阳极
sbit SER_B = P2^5;
sbit RCLK_B = P2^6;
sbit SRCLK_B = P2^7;

控制阴极的两组595需要至少将OE(输出使能)引脚留下来,因为在点阵刷新的时候需要先将Q0至Q15输出引脚置为高阻态,否则会有闪烁。控制共阳极的一组595的SRCLR(清空移位寄存器)和OE则出于节省IO资源的考虑分别接高电平和低电平。
材料清单

  1. M1588BRG红绿双色点阵*2
  2. 74595八位移位寄存器*6

数码管模块

数码管模块也可以使用595驱动,但是注意595本质上是完成了串行输入并行输出的转换,所以每增加一片595,MCU就要多输出8位串行数据,如果需要输出的串行比特过多,会导致扫描频率下降,为了避免这种情况,本设计采用7447(BCD到七段译码器)和74138(三八译码器)以及若干晶体管绘制数码管的驱动电路。
原理图

晶体管是不可缺少的,虽然7447有驱动能力,但是74138的输出引脚低电平有效无法控制四个共阳极引脚,且驱动力不足,所以本设计使用S9012晶体管(PNP)制成OC反相驱动器。使用P1控制上述六个输入引脚。注意一定要有基极限流电阻,否则晶体管会因基极电流过大而烧坏。此外,上述原理图没有集电极限流电阻的原因是7447对电流的钳制作用。
材料清单

  1. MT03641BR四位红色共阳极数码管*1
  2. S9012 PNP型晶体管*4
  3. 7447译码器*1
  4. 74138译码器*1
  5. 限流电阻10k*4

节拍信号发生电路

贪吃蛇游戏需要一个节拍信号使蛇向前走,本设计使用外部时钟产生这个节拍信号,这个信号的精度不需要很高,只需要让人感觉不到频率漂移即可。节拍信号应该是秒级的,实现有两种方案,一种是晶振分频法,一种是使用RC振荡器,本设计采用后者。RC振荡器的精度较差,但是人无法感知这样的误差,此外,如果使用可变电阻构成振荡器,可以通过机械旋钮调节节拍快慢,以此调节游戏难度。本设计也利用RC振荡器精度较差的特点生成随机数。该外部时钟是基于NE555(555定时器)的非稳态模式(ASTABLE MODE)。
原理图

根据NE555数据手册可知,上述电路输出频率大致在1Hz至6Hz之间。
材料清单

  1. NE555定时器*1
  2. 金属膜电阻 11k
  3. 金属膜电阻 21k
  4. 独石电容 10nF
  5. 变阻器 max 200k

蜂鸣器电路

本设计采用无源蜂鸣器,有源蜂鸣器在软件和硬件上更加方便,但是笔者只有现成的无源蜂鸣器。驱动电路如下
原理图

其中在蜂鸣器两端并联的二极管是不可缺少的,蜂鸣器是感性原件,在振荡时会产生电压尖峰。
材料清单

  1. 无源蜂鸣器*1
  2. 1N4148肖特基二极管*1
  3. S9013 NPN型三极管*1
  4. 限流电阻 10k *1

串口驱动电路、按键电路及MCU最小系统

为了方便调试,本设计中集成了串口驱动电路,使用的是CH340(国产USB转串口芯片)。
本设计中采取独立按键设计,共计5个按键使用了5个IO口,引脚定义如下

sbit BUTTON_UP = P3^6;
sbit BUTTON_DOWN = P3^4;
sbit BUTTON_LEFT = P3^7;
sbit BUTTON_RIGHT = P3^3;
sbit BUTTON_MIDDLE = P3^5;

89C52最小系统及按键原理图

为了获得更好的性能,本设计使用22.1184MHz晶振。
串口驱动电路原理图
[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)_第1张图片
串口驱动电路原理图来自数据手册。
材料清单

  1. 89C52单片机 *1
  2. 22.1184MHz直插式晶体振荡器 *1
  3. 匹配电容22pF *2
  4. 按键 *5
  5. 上拉电阻10k *5
  6. CH340 USB转UART芯片
  7. 12MHz直插式晶体振荡器 *1
  8. 匹配电容22pF *2
  9. microUSB母座 *1

软件设计

重要数据结构

蛇的身体由一系列有序体节组成,每个体节在点阵中显示为一个点,在程序中蛇应该是一个线性结构。在节拍信号到来时,蛇会向左方、前方或右方前进一格,在程序中应该是添加一个位置作为头,删除最后一个体节(尾),所以蛇应该被抽象为一个队列结构。如果使用简单数组的话,这个动作会产生大量的拷贝动作,不适合于MCU有限的硬件资源。实现队列的经典数据结构是链表,链表在删除或添加元素的过程中不需要移动其他元素。但是链表结构不便于用在MCU上,因为MCU中没有内存管理算法,而链表的动作需要涉及到大量内存的申请与释放。
本设计中使用的数据结构是循环数组,循环数组可以有和链表一样的功能,但是有容量上限,但这在本设计中不成问题,因为蛇的最大长度是128。数据结构的实现代码如下:

char xdata snake[AREA_SIZE];
unsigned char ptrHead = 0;
unsigned char ptrTail = 0;
unsigned char length = 0;

上面是数据结构成员,整个数据结构的图示如下图:
[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)_第2张图片
头指针ptrHead指向蛇头前第一个空元素,尾指针ptrTail指向蛇尾。数据结构中的length变量是为了区分队列空和队列满两种情况(一般不会用到,因为玩到蛇身充满整个屏幕十分困难,但是length是一个相对常用的值且出于结构完备性的考虑,还是将 length变量独立出来),还有助于快速获取蛇的长度。宏常量AREA_SIZE是点阵上的点数,同时也是最大的蛇身长。
当希望把一个位置加入蛇身的时候,只需要将值放入ptrHead所指的位置,并且将ptrHead前移一格,实现代码如下:

void AddHead(char newNode){
	char ytemp = GETY(newNode);
	char xtemp = GETX(newNode);
	snake[ptrHead] = newNode;//将新位置入队作为蛇头
	ptrHead = (ptrHead+1)%AREA_SIZE;//头指针指向下一个位置
	length++;//长度加一
	vram[ytemp][xtemp] = (xtemp+ytemp)%2==0?1:2;
}

最后一行代码和显存有关,这里不展开。如果想删除蛇尾,那么只需要将ptrTail前移,代码如下

void DeleteTail(){
	RESET_VRAM(snake[ptrTail]);
	ptrTail = (ptrTail+1)%AREA_SIZE;//尾指针前移
	length--;//长度减一
}

这里注意到两个指针的增加方式都是模128加法,这正是循环的含义,127增加后会变为0
最后,可能有人会好奇为什么队列元素是char类型的,为什么一个8bit的变量能表示一个位置。本次设计的点阵是16*8的,所以如果让0-3bit表示x值,4-6bit表示y值,正好可以表示点阵上所有的点。
这样做的理由有两个:一是可以节约单片机紧张的存储资源;二是本设计中的方向控制计算方式需要的,将在后文展开。

显存

显存(Video RAM)的数据结构十分简单,本质上是一个二维数组(甚至在单色实现下可以将之压缩为一个一维数组)。

char xdata vram[ROW_SIZE][COLUMN_SIZE];
//{
//	{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
//	{0,2,2,0,0,2,2,0,0,1,1,0,0,1,1,0},
//	{2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1},
//	{2,2,3,2,2,3,2,2,1,1,1,1,1,1,1,1},
//	{0,2,2,3,3,2,2,0,0,1,1,1,1,1,1,0},
//	{0,0,2,2,2,2,0,0,0,0,1,1,1,1,0,0},
//	{0,0,0,2,2,0,0,0,0,0,0,1,1,0,0,0},
//	{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
//};

其中宏常量ROW_SIZE,COLUMN_SIZE是行数和列数,分别等于8和16。由于本设计实现的是三色显示,所以一个bit无法表示一个LED的状态,所以使用char类型的二维数组是必要的。本设计中约定0x00代表灭,0x01代表颜色A,0x02代表颜色B,0x03代表黄。颜色A和颜色B只可能是红色或绿色,为了实现蛇身的红绿交替,颜色A和颜色B随节拍交替变换为红色或绿色。具体实现见后文。如果颜色A为红色,颜色B为绿色,那么上面代码中被注释掉的数组会显示为:
[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)_第3张图片

驱动

LED点阵驱动

本小节将自底向上描述显存是如何显示在点阵上的点的。

74595驱动

硬件设计部分中,一共有6颗595芯片,两两级联形成3个16位移位寄存器。并且为了布线方便,一个起到了行选的功能,一个控制左边8*8点阵的双色LED的列选,另一个控制右边8*8点阵的双色LED的列选。MCU每次需要输出24个串行比特才能扫描16*8点阵上的一行。将之输出需要两步:将串行数据加载到移位寄存器中,将数据输出到595输出引脚,控制代码如下:

void Load_74HC595(int dat_B, int dat_L, int dat_R){
	char i;
	for(i=0;i<16;i++){
		SER_B=dat_B&0x8000;
		dat_B=dat_B<<1;
		SER_L=dat_L&0x8000;
		dat_L=dat_L<<1;
		SER_R=dat_R&0x8000;
		dat_R=dat_R<<1;
	 
		SRCLK_B=1;
		SRCLK_L=1;
		SRCLK_R=1;
		SRCLK_B=0;
		SRCLK_L=0;
		SRCLK_R=0;
	}
}
void Output_74HC595(){
	RCLK_B=1;
	RCLK_L=1;
	RCLK_R=1;
	RCLK_B=0;
	RCLK_L=0;
	RCLK_R=0;
}

上述代码如果预先知道595的工作时序很容易读懂,需要说明的有两点:

  1. 没有延时函数_nop_(),这是因为如果轮流操作三个16位寄存器(相当于时分复用),语句之间可以起到延时作用
  2. 网络上关于595的资料中,加载函数总是喜欢用SER_B=dat_B>>15;这样一句话。本设计中使用的是SER_B=dat_B&0x8000;。实际上如果使用前一句话本设计是很难正常工作的,因为右移15位的运算需要较长的时间,一共需要填入24bit数据,会使每次的装填时间延迟过长,结果是扫描频率过低,经实验证明,即使CPU资源一直被timer0中断函数占用(这是不合适的,因为主函数和外部终端也需要使用CPU),点阵也会有明显的闪烁感。也正是因为在本设计中时间资源也是很紧张的,所以选择使用了22.1184MHz的晶振,既能保证准确的19200波特率,也几乎达到了51单片机允许的时钟信号上限(24MHz)

点阵扫描

if(!isRefreshing){
	OE_R = 1;
	OE_L = 1;
	Load_74HC595(rowSelectCode[rowSelect],leftColSelectCode[refreshCounter][rowSelect],rightColSelectCode[refreshCounter][rowSelect]);
	Output_74HC595();
	OE_R = 0;
	OE_L = 0;
}

直接调用595驱动函数的是上面的一段代码(timer0中断函数的一部分),起到了扫描器的作用,rowSelect是在从0到7循环的量(对应的是第1到第8行点阵),在每次timer0中断这个量会增加。
由于我使用的是洞洞板,布线空间紧张,所以所有引脚的接线都是选择了硬件上的方便而舍弃了软件上的方便,毕竟在软件上增加译码步骤要比在硬件上到处飞线方便很多。数组leftColSelectCoderightColSelectCode起到的作用是缓冲,是扫描器和后面的刷新器之间的接口,这是由于译码步骤相对比较复杂而屏幕刷新频率(注意刷新和扫描的区别,扫描是毫秒级的动作,而在本设计中刷新是秒级的,刷新的作用是变更显示内容)较低。如果将译码步骤放在扫描器中会使timer0中断函数执行时间过长。rowSelectCode在后文中解释。

点阵刷新

每次节拍信号到来时,点阵需要刷新,就是通过修改leftColSelectCode、rightColSelectCode实现的,游戏逻辑直接控制的是显存vram[8][16],译码步骤需要实现从vram到两个数组上映射。刷新器代码如下:

void refresh(){
	char i,j;
	refreshCounter_buffer=refreshCounter+1;
	refreshCounter_buffer%=2;
	for(i=0;i<8;i++){
		leftColSelectCode[refreshCounter_buffer][i] = 0xFFFF;
		rightColSelectCode[refreshCounter_buffer][i] = 0xFFFF;
		for(j=0;j<8;j++){
			leftColSelectCode[refreshCounter_buffer][i] &= (vram[i][j]&0x01)!=0    ? columnSelectCode[refreshCounter][LEFT][j]   : 0xFFFF;
			leftColSelectCode[refreshCounter_buffer][i] &= (vram[i][j]&0x02)!=0  ? columnSelectCode[refreshCounter_buffer][LEFT][j] : 0xFFFF;
			rightColSelectCode[refreshCounter_buffer][i]&= (vram[i][j+8]&0x01)!=0  ? columnSelectCode[refreshCounter][RIGHT][j]  : 0xFFFF;
			rightColSelectCode[refreshCounter_buffer][i]&= (vram[i][j+8]&0x02)!=0? columnSelectCode[refreshCounter_buffer][RIGHT][j]: 0xFFFF;
		}
	}
	isRefreshing = 1;
	refreshCounter=refreshCounter_buffer;
	isRefreshing = 0;
}

其中,rowSelectCode

int code rowSelectCode[] = {0x0180,0x0204,0x2002,0x4001,0x8008,0x1010,0x0820,0x0440};

columnSelectCode

int code columnSelectCode[2][2][ROW_SIZE] = {
	{
		{0xFF7F, 0xFFBF, 0xFFDF, 0xFFEF, 0xFFF7, 0xFFFB, 0xFFFD, 0xFFFE},
		{0xFF7F, 0xFFBF, 0xFFDF, 0xFFEF, 0xFFF7, 0xFFFB, 0xFFFD, 0xFFFE}
	},
	{
		{0x7FFF, 0xBFFF, 0xDFFF, 0xEFFF, 0xF7FF, 0xFBFF, 0xFDFF, 0xFEFF},
		{0x7FFF, 0xBFFF, 0xDFFF, 0xEFFF, 0xF7FF, 0xFEFF, 0xFDFF, 0xFBFF}
	}
};

rowSelectCode、columnSelectCode实现了从行编号和列编号到595输出上的映射,编号规则如下
[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)_第4张图片

行编号是每一行对应的编码,列编号是一个三维数组,三个维度代表的意义分别是颜色(红、绿),点阵(左、右),列号(0-7)
这一段对有充分布线空间的人帮助不大,因为如果将595和点阵的引脚从LSB到MSB都对应好,软件代码会大大简化,不必包含显式的译码过程。但是本段提供了一种有用的思路,这个思路在本设计的数码管扫描中也有出现。
到此为止本节描述了本设计从595到vram的结构,vram最终给游戏的逻辑代码以点阵显示接口。

数码管驱动

数码管驱动的结构相对简单,硬件上数码管的段选引脚控制是由7447驱动译码器(BCD to 7-Seg)实现的,位选引脚控制是由74138(三线-八线译码器,当然四个共阳极和译码器之间还有四支PNP三极管,起到了反相驱动器的作用)实现的,7447的四个引脚加上74138的两个引脚(最高位弃而不用,因为MT03641BR是四位数码管)的六个引脚直接与MCU的IO口相连,完成四位数的显示。沿袭点阵驱动的译码器的思路,数码管扫描中也用了译码器和缓冲器,扫描器直接访问的是缓冲器:

NEXIE_PORT = digitSelectCode[digitSelect]|numberCode[digits[digitSelect]];

其中digitSelect变量是从0到3循环变化的,作用和rowSelect类似。缓冲器是数组digits[4]宏常量NEXIE_PORT是P1,P1的六个引脚控制数码管。

char digits[4];//digits on nixie

digitSelectCode数组建立了从扫描变量digitSelect位选信号的映射;numberCode数组建立了从缓冲器digits段选信号的映射。

char code digitSelectCode[] = {0x30,0x00,0x20,0x10};
char code numberCode[] = {0x00,0x01,0x08,0x09,0x04,0x05,0x0C,0x0D,0x02,0x03,0xFF};

最终向游戏逻辑提供的数码管接口是digits[4]

蜂鸣器驱动

蜂鸣器在本设计中实现的功能十分简单,只需要发出一个短促的单音,不需要播放音乐(如需要播放音乐可使用timer2,这是52系列特有的)。本设计选择将蜂鸣器的控制端口也放在Port 1,每次timer0溢出中断蜂鸣器引脚的状态就会翻转(这也确定了振动的频率是中断频率的二分之一)。如果使用有源蜂鸣器甚至不需要震荡信号(只是我手头没有有源蜂鸣器)。蜂鸣器向游戏逻辑提供的接口是一个bit类型的变量isBuzzing,将在后文解释。

计时器0的复用

计时器0控制着数码管、点阵、蜂鸣器的扫描(振动)信号,三者复用一个计时器,但是注意要让中断函数尽量精简,仅仅保留扫描功能,其他的功能尽量扔到外部处理,否则会导致扫描频率过低进而产生闪烁感,首先是完整的Timer0中断函数:

void Timer0_IRQ(void) interrupt 1{
//第一段
	interruptCounter++;
	if(isBuzzing){
		isBuzzing=((buzzCounter++)<=0x1F);
	}
	digitSelect = interruptCounter&0x03;
	rowSelect = interruptCounter&0x07;
	buzz = interruptCounter&(isBuzzing?0x01:0x00);
	
//第二段
	NEXIE_PORT = 
		digitSelectCode[digitSelect] |
		numberCode[digits[digitSelect]] |
		colorfulLightCode[colorfulLight] |
		buzzerCode[buzz];
	if(!isRefreshing){
		OE_R = 1;
		OE_L = 1;
		Load_74HC595(rowSelectCode[rowSelect],leftColSelectCode[refreshCounter][rowSelect],rightColSelectCode[refreshCounter][rowSelect]);
		Output_74HC595();
		OE_R = 0;
		OE_L = 0;
	}
	
//计数器复位
	TH0=SCAN_PERIOD_H;
	TL0=SCAN_PERIOD_L;
}

最后两句话是对计数器寄存器的重装。

产生周期变量

代码第一段目的是产生三个周期变化的变量。
数码管扫描变量digitSelect = interruptCounter&0x03;
点阵扫描变量rowSelect = interruptCounter&0x07;
蜂鸣器震荡变量buzz

if(isBuzzing){
	isBuzzing=((buzzCounter++)<=0x1F);\\isBuzzing会自动复原
}
buzz = interruptCounter&(isBuzzing?0x01:0x00);

前两个已经在前文有所解释,buzz变量直接控制了蜂鸣器的振动,标志位isBuzzing的作用是控制蜂鸣器是否发声(如果isBuzzing=false,那么buzz一定为0x00),同时也是蜂鸣器对游戏逻辑的接口。
上面的if语句的作用是让isBuzzing自动复原,buzzCounter记录了蜂鸣器发声的时间,在isBuzzing被置位的同时buzzCounter被复位。

产生扫描信号

NEXIE_PORT = 
	digitSelectCode[digitSelect] |
	numberCode[digits[digitSelect]] |
	colorfulLightCode[colorfulLight] |
	buzzerCode[buzz];
if(!isRefreshing){
	OE_R = 1;
	OE_L = 1;
	Load_74HC595(rowSelectCode[rowSelect],leftColSelectCode[refreshCounter][rowSelect],rightColSelectCode[refreshCounter][rowSelect]);
	Output_74HC595();
	OE_R = 0;
	OE_L = 0;
}

P1引脚控制了两样东西(在我的实物中是三样,多了一个LED彩灯,给高电平就会发出彩光),一个是数码管一个是蜂鸣器,所以这个赋值语句就是把各个引脚需要输出的信号拼接组合起来然后输出
后面的if语句在前面点阵驱动部分已经有解释,需要补充的是标志变量isRefreshing,作用是在点阵缓冲器leftColSelectCoderightColSelectCode刷新的时候停止输出点阵扫描信号isRefreshing是必要的,否则在节拍信号到来时点阵会有额外的闪光。

EEPROM驱动

EEPROM的驱动网上有大量代码,本设计中使用的代码来自
STC89C52RC内部EEPROM的读写 BY waitstory12

//eeprom.h
#ifndef _EEPROM_H_
#define _EEPROM_H_
 
#include 
#include 
 
typedef  unsigned int uint;
typedef  unsigned char uchar;

/********STC89C52 section*******
1st section:2000H--21FF
2nd section:2200H--23FF
3rd section:2400H--25FF
4th section:2600H--27FF
5th section:2800H--29FF
6th section:2A00H--2BFF
7th section:2C00H--2DFF
8th section:2E00H--2FFF
*******************************/ 
 
#define RdCommand 0x01  
#define PrgCommand 0x02 
#define EraseCommand 0x03
 
#define Error 1
#define Ok 0
#define WaitTime 0x01
  
sfr ISP_DATA = 0xE2;
sfr ISP_ADDRH = 0xE3;
sfr ISP_ADDRL = 0xE4;
sfr ISP_CMD = 0xE5;
sfr ISP_TRIG = 0xE6;
sfr ISP_CONTR = 0xE7;
 
 
unsigned char byte_read(unsigned int byte_addr);
void byte_write(unsigned int byte_addr,unsigned char Orig_data);
void SectorErase(unsigned int sector_addr);
 
#endif
//eeprom.c
#include "eeprom.h"
   
void ISP_IAP_Enable(void)
{
    EA = 0;
    ISP_CONTR = ISP_CONTR & 0x18;  
    ISP_CONTR = ISP_CONTR | WaitTime;
    ISP_CONTR = ISP_CONTR | 0x80;
}
   
void ISP_IAP_Disable(void)
{
	 ISP_CONTR = ISP_CONTR & 0x7f;
     ISP_CMD = 0x00;
	 ISP_TRIG = 0x00;
	 EA   =   1;
}

void ISPTrig(void)
{
	 ISP_TRIG = 0x46;
	 ISP_TRIG = 0xb9;
	 _nop_();
}
 

unsigned char byte_read(unsigned int byte_addr)
{
   unsigned char  dat = 0;
 
	 EA = 0;
	 ISP_ADDRH = (unsigned char)(byte_addr >> 8);
	 ISP_ADDRL = (unsigned char)(byte_addr & 0x00ff);
     ISP_IAP_Enable();
	 ISP_CMD   = ISP_CMD & 0xf8;
	 ISP_CMD   = ISP_CMD | RdCommand;
	 ISPTrig();
	 dat = ISP_DATA;
     ISP_IAP_Disable();
	 EA  = 1;
	 return dat;
}

void byte_write(unsigned int byte_addr,unsigned char Orig_data)
{
	 EA  = 0;
	 ISP_ADDRH = (unsigned char)(byte_addr >> 8);
	 ISP_ADDRL = (unsigned char)(byte_addr & 0x00ff);
	 ISP_IAP_Enable();
     ISP_CMD  = ISP_CMD & 0xf8;
	 ISP_CMD  = ISP_CMD | PrgCommand;
	 ISP_DATA = Orig_data;
	 ISPTrig();
	 ISP_IAP_Disable();
	 EA =1;
}

void SectorErase(unsigned int sector_addr)
{
	 EA = 0;   
	 ISP_ADDRH = (unsigned char)(sector_addr >> 8);
	 ISP_ADDRL = (unsigned char)(sector_addr & 0x00ff);
	 ISP_IAP_Enable(); 
     ISP_CMD = ISP_CMD & 0xf8;
	 ISP_CMD = ISP_CMD | EraseCommand;
	 ISPTrig();
	 ISP_IAP_Disable();
}

节拍信号和随机数生成

在硬件设计部分我们已经知道本设计的节拍信号是由NE555(555定时器)提供的,这里先说明使用NE555的理由。
与晶振不同,555定时器的频率由RC振荡器的本征频率决定,其电容和电阻,乃至芯片内部器件的状态收到诸多因素干扰,精度较差。根据中心极限定理,555定时器的频率分布应该是一个方差较大的高斯函数。如果Timer1(被配置为8位自动填充工作模式)溢出的频率大于分布的期望的最高精度,那么在每次节拍信号到来时读取TL1寄存器的值,结果将是完全随机的。通过计算,Timer1的溢出频率应该是[email protected]。印象中555定时器的频率稳定性在千分之一的数量级上。经过粗略计算分析通过NE555产生节拍信号,在节拍信号到来时(秒级频率)读取TL1寄存器可以产生质量较好的随机数。并且为了进一步提高干扰,本设计中555定时器的电容电阻都没有使用贴片式。

实验:随机性的大致验证

通过串口将产生的随机数发送给MATLAB,将随机数数列曲线绘制成曲线。

上图是单片机生成了3000个随机数后绘制的曲线,曲线通过肉眼观察不到明显的模式,通过实验简单证明了上述手段的有效性(该实验不能证明数列是严谨的随机数列,严谨的证明需要更多的信息论和统计理论知识)。
注意每次只能取一个8bit值,第二个取值不是和第一个独立的。如果从TL1取出两个值,由于取值指令是由晶振控制的,那么显然这两个坐标一定有着固定的间隔。只取一个8bit值对本设计足够了,因为8bit有256个可能,但是点阵上只有128个点。
最后我们需要简单测试随机数的均匀性(样本点10000个)。

上面的分布柱状图显示这128个数出现的频数比较相近,基本满足随机数的均匀性。本部分的结论是:使用NE555定时器产生节拍信号,既解决了产生随机数的问题,其频率稳定性也足以应付人的感官了。本设计中NE555产生的信号在1Hz到6Hz之间。

节拍动作和状态机

接下来我们开始描述游戏逻辑部分的设计。游戏是由周期性的节拍和异步的按键信号控制的。本小节先描述当节拍信号到来时MCU的动作。
首先是外部中断0的中断函数

void EX0_IRQ(void) interrupt 0{
	state=FSM();
	if(state!=DEAD){
		refresh();
	}
}

状态机:接收输入

节拍动作的第一步是进行状态更新、点阵刷新、相关状态变量更新。

char FSM(){
	switch(state){
		case ALIVE:
			forwardDirection=GO_STRAIGHT;
			break;
		case TURN_NORTH:
			if(orientation==TOWARD_WEST)		{forwardDirection=TURN_RIGHT;}
			else if(orientation==TOWARD_EAST)	{forwardDirection=TURN_LEFT;}
			else							{forwardDirection=GO_STRAIGHT;}
			break;
		case TURN_EAST:
			if(orientation==TOWARD_NORTH)		{forwardDirection=TURN_RIGHT;}
			else if(orientation==TOWARD_SOUTH){forwardDirection=TURN_LEFT;}
			else							{forwardDirection=GO_STRAIGHT;}
			break;
		case TURN_SOUTH:
			if(orientation==TOWARD_EAST)		{forwardDirection=TURN_RIGHT;}
			else if(orientation==TOWARD_WEST) {forwardDirection=TURN_LEFT;}
			else							{forwardDirection=GO_STRAIGHT;}
			break;
		case TURN_WEST:
			if(orientation==TOWARD_SOUTH)		{forwardDirection=TURN_RIGHT;}
			else if(orientation==TOWARD_NORTH){forwardDirection=TURN_LEFT;}
			else							{forwardDirection=GO_STRAIGHT;}
			break;
		default:
			forwardDirection=GO_STRAIGHT;
			break;
	}
	return Creep();
}

上面代码中出现了大量的宏常量,大致分为三类:

  1. 状态机状态:生、死、按键输入的方向
#define ALIVE 0x00
#define TURN_NORTH 0x01
#define TURN_EAST 0x02
#define TURN_SOUTH 0x03
#define TURN_WEST 0x04
#define DEAD 0x05
char state;
  1. 蛇头朝向:东、南、西、北
#define TOWARD_NORTH 0x00
#define TOWARD_EAST 0x01
#define TOWARD_SOUTH 0x02
#define TOWARD_WEST 0x03
char orientation;
  1. 前进方向:向左、向右、直行
#define TURN_LEFT 0xFF
#define TURN_RIGHT 0x01
#define GO_STRAIGHT 0x00
char forwardDirection;

FSM()中,在前面的整个switch语句用于结合蛇头朝向和按键输入判断蛇的前进方向。比如如果蛇头向东,那么只有TURN NORTHTURN SOUTH按键被按下,蛇才会向左或向右前进,而TURN WEST(贪吃蛇游戏中不存在直接调头的行为)和TURN EAST(蛇头已经向东)是无效的。其他三个case分支与之类似。FSM()第一部分为Creep()提供了必要的状态变量。
第二部分功能由Creep()完成,并返回Creep()的返回值(Creep返回ALIVE或者DEAD)。所以如果蛇在下一次爬行中活下来,状态总会回归ALIVE,而状态DEAD意味着游戏的终结,游戏的终止判断将在主循环中完成

节拍动作:更新vram并判断生死

char Creep(){
	nextOrientation = (orientation+forwardDirection)&0x03;
	currentPosition = snake[(ptrTail+length-1)%AREA_SIZE];
	nextPosition = currentPosition+forwardCode[nextOrientation];
	currentX = GETX(currentPosition);
	currentY = GETY(currentPosition);
	biteSelf = VRAM(GETX(nextPosition),GETY(nextPosition));
	hitWall = 
		(currentX==0				&&	nextOrientation==TOWARD_WEST) ||
		(currentX==COLUMN_SIZE-1	&&	nextOrientation==TOWARD_EAST) ||
		(currentY==0				&&	nextOrientation==TOWARD_NORTH) ||
		(currentY==ROW_SIZE-1		&&	nextOrientation==TOWARD_SOUTH);
	if(nextPosition==food){
		Score();
	}
	else if(biteSelf||hitWall){
		return DEAD;
	}
	else{
		AddHead(nextPosition);
		DeleteTail();
	}
	orientation = nextOrientation;
	return ALIVE;
}

FSM()函数向Creep()通过全局变量传递的参数是forwardDirection
[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)_第5张图片
上图是蛇头朝向和前进方向的编码规则。0xFF相当于减一表示向左转,0x01相当于加一代表向右转,0x00表示方向不变即直行,注意如果蛇头朝北且将向左转时,或者蛇头向西且将向右转时,就会产生溢出,所以上述加减法必须是模4加和模4减。通过nextOrientation = (orientation+forwardDirection)&0x03;的运算就能计算出下一个朝向
第二条语句currentPosition = snake[(ptrTail+length-1)%AREA_SIZE];的作用是取出蛇头元素
第三条语句nextPosition = currentPosition+forwardCode[nextOrientation];是为了根据当前蛇头的位置和前进方向判断出蛇头的下一个位置。这里又出现了一个表格数组forwardCode

char code forwardCode[4] = {0xF0, 0x01, 0x10, 0xFF};

根据我们对位置的编码规则,0-3bit代表X值,4-6bit代表Y值,bit 7的状态对位置没有影响。

  1. 向北前进的编码是0xF0,作用是将4-6bit上的二进制数减一(Y值减一)
  2. 向东前进的编码是0x01,作用是将0-3bit上的二进制数加一(X值加一)
  3. 向南前进的编码是0x10,作用是将4-6bit上的二进制数加一(Y值加一)
  4. 向西前进的编码是0xFF,作用是将0-3bit上的二进制数减一(X值减一)

注意到这条语句没有模运算,这是因为蛇不可能越过边界,所以溢出和进位导致的异常就不予处理了(其实为了算法的完备性,这里应该有取模运算,但是没有并不影响功能,所以就不进行了)。

currentX = GETX(currentPosition);
currentY = GETY(currentPosition);
biteSelf = VRAM(GETX(nextPosition),GETY(nextPosition));
hitWall = 
	(currentX==0				&&	nextOrientation==TOWARD_WEST) ||
	(currentX==COLUMN_SIZE-1	&&	nextOrientation==TOWARD_EAST) ||
	(currentY==0				&&	nextOrientation==TOWARD_NORTH) ||
	(currentY==ROW_SIZE-1		&&	nextOrientation==TOWARD_SOUTH);

上面代码中出现了一些宏函数

#define GETX(position) (position)&0x0F
#define GETY(position) ((position)>>4)&0x07
#define RESET_VRAM(position) vram[GETY(position)][GETX(position)] = 0;
#define VRAM(x,y) vram[y][x]

仅仅是为了简化代码,宏函数本身不难理解
bitSelf是一个标志位,代表蛇是否咬到了自己,这里显存vram正好起到了哈希表的作用,使程序在常数级时间下就能判断出蛇是否因为咬到自己而死亡。
hitWall是一个标志位,代表蛇是否撞到了边界。根据蛇头当前位置和蛇的下一步前进方向进行判断。

	if(nextPosition==food){
		Score();
	}
	else if(biteSelf||hitWall){
		return DEAD;
	}
	else{
		AddHead(nextPosition);
		DeleteTail();
	}
	orientation = nextOrientation;
	return ALIVE;

最后主要是一个三份支的if语句,根据前面计算出的标志位,进行显存刷新和状态判断:

  1. 下一个位置是食物,则得分
  2. 如果咬到自己或撞上了边界则蛇死亡游戏结束
  3. 否则仅仅让蛇前进

得分动作

void Score(){
	char i;
	AddHead(food);
	SetFood();
	point++;
	isBuzzing=1;
	buzzCounter=0;
	for(i=0;i<4;i++){
		digits[i]++;
		if(digits[i]>=10){
			digits[i]-=10;
		}
		else{
			break;
		}
	}
}

得分的动作简单而细碎

  1. 蛇的长度增加,只增加头不去掉尾
  2. 放置新的食物
    前面已经提到本设计使用TL1产生一个0-127的随机数,虽然0-127能够映射到点阵上的每一点,但是为了避免食物与蛇身重叠,生成的随机数被映射到显存上所有的空白点(显然这不是双射,但是是满射),通过不断取随机数直到取到空白点也能避免食物与蛇身重叠,但这在空白点较少的时候十分低效,可能造成无法运行。
void SetFood(){
	char i,j,k;
	rand = TL1&0x7F;//get random number
	rand = rand%(AREA_SIZE-length)+1;
	k=0;
	for(i=0;i<8;i++){
		for(j=0;j<16;j++){
			if(vram[i][j]==0){
				k++;
				if(k>=rand){
					vram[i][j]=3;//黄色
					food = j|(i<<4);
					return;
				}
			}
		}
	}	
}
  1. 积分器point加一
  2. 初始化蜂鸣器,发出短促的单音
  3. 刷新数码管缓存器

AddHead()函数的补充:红绿蛇身的实现方法

void AddHead(char newNode){
	char ytemp = GETY(newNode);
	char xtemp = GETX(newNode);
	snake[ptrHead] = newNode;//将新位置入队作为蛇头
	ptrHead = (ptrHead+1)%AREA_SIZE;//头指针指向下一个位置
	length++;//长度加一
	vram[ytemp][xtemp] = (xtemp+ytemp)%2==0?1:2;
}

对蛇的数据结构的描述部分遗留了一个问题,语句vram[ytemp][xtemp] = (xtemp+ytemp)%2==0?1:2;的作用是刷新显存,但是又不止如此。
该运算相当于给点阵赋予了两种符号A和B(就是显存数据结构中的颜色A和颜色B)
[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)_第6张图片
由于蛇身只可能走直角弯,又因为点阵上一点的四个方向上相邻点的符号都与该点不同,这就保证了蛇身的红绿交替。最后通过在每个节拍时都翻转A和B的颜色,就能保证蛇的每个体节颜色恒定,示意图如下:
[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)_第7张图片
[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)_第8张图片

节拍动作:刷新595缓冲数组

void refresh(){
	char i,j;
	refreshCounter_buffer=refreshCounter+1;
	refreshCounter_buffer%=2;
	isRefreshing = 1;
	for(i=0;i<8;i++){
		leftColSelectCode[refreshCounter_buffer][i] = 0xFFFF;
		rightColSelectCode[refreshCounter_buffer][i] = 0xFFFF;
		for(j=0;j<8;j++){
			leftColSelectCode[refreshCounter_buffer][i] &= (vram[i][j]&0x01)!=0    ? columnSelectCode[refreshCounter][LEFT][j]   : 0xFFFF;
			leftColSelectCode[refreshCounter_buffer][i] &= (vram[i][j]&0x02)!=0  ? columnSelectCode[refreshCounter_buffer][LEFT][j] : 0xFFFF;
			rightColSelectCode[refreshCounter_buffer][i]&= (vram[i][j+8]&0x01)!=0  ? columnSelectCode[refreshCounter][RIGHT][j]  : 0xFFFF;
			rightColSelectCode[refreshCounter_buffer][i]&= (vram[i][j+8]&0x02)!=0? columnSelectCode[refreshCounter_buffer][RIGHT][j]: 0xFFFF;
		}
	}
	refreshCounter=refreshCounter_buffer;
	isRefreshing = 0;
}

refresh函数的作用有

  1. 译码,上文已有解释
  2. 根据刷新次数的奇偶翻转A和B的颜色

注意refresh函数只能在state=ALIVE时执行,如果state=DEAD也执行,蛇头(第一个红色点)会消失。

主循环和按键检测

while(1){
	if(state==DEAD){
		EX0=0;//切断节拍信号
		if(point>=highestPoint){//记录最高分
			SectorErase(0x2000);
			byte_write(0x2001,digits[0]);
			byte_write(0x2002,digits[1]);
			byte_write(0x2003,digits[2]);
			byte_write(0x2004,digits[3]);
			highestPoint=point;
			colorfulLight=1;//如果最高分诞生,打开彩灯
		}
		else{
			colorfulLight=0;
		}
		while(BUTTON_MIDDLE){
		}//等待用户开启新一轮游戏
		restart();//重置所有状态
		for(i=0;i<4;i++){//数码管复位
			digits[i]=0;
		}
		EX0=1;//接通节拍信号
	}
	else if(state==ALIVE){//异步信号改变状态机状态
		if(state==ALIVE&&BUTTON_UP==0){
			state = TURN_NORTH;
		}
		else if(state==ALIVE&&BUTTON_DOWN==0){
			state = TURN_SOUTH;
		}
		else if(state==ALIVE&&BUTTON_LEFT==0){
			state = TURN_WEST;
		}
		else if(state==ALIVE&&BUTTON_RIGHT==0){
			state = TURN_EAST;
		}
	}
	else{//如果已经有输入,则无动作
	}
}

主循环语句块,只包含了一个冗长的条件判断语句,其中包含三个分支:

  1. 死亡
  2. 游戏进行中且本节拍内没有输入
  3. 游戏进行中且本节拍内已有输入,避免重复操作并且按键防抖

需要注意的是,ALIVE分支的四个按键检测里对state又进行了检测,这个冗余判断是为了防止在节拍信号到来产生外部中断后,state可能变为DEATH,此时返回主函数时程序已经进入ALIVE分支,然后依然检测了按键状态,导致蛇“起死回生”的bug。

初始化和寄存器配置

void initializer(){
	char i;
	//计时器1八位重装工作模式,计时器0十六位溢出中断模式
	TMOD=0x21;
	//装载计时器0初始值
	TH0=SCAN_PERIOD_H;
	TL0=SCAN_PERIOD_L;
	//装载计时器1初始值
	TH1=0x00;
	TL1=0x00;
	//启动计数
	TR0=1;
	TR1=1;
	//外部中断0负边沿触发
	IT0=1;
	//计时器0中断优先于外部中断,否则点阵会有闪烁
	IP=0x02;
	
	digits[0] = byte_read(0x2001);
	digits[1] = byte_read(0x2002);
	digits[2] = byte_read(0x2003);
	digits[3] = byte_read(0x2004);
	for(i=0;i<3;i++){
		highestPoint += digits[3-i];
		highestPoint *= 10;
	}
	highestPoint += digits[0];
	
	//重置所有游戏状态
	restart();
	
	//使能计数器0中断,关闭计数器1中断
	ET0=1;
	ET1=0;

	//开启总中断
	EA=1;
}

重启函数

void restart(){
	char i,j;
	//清空显存
	for(i=0;i<8;i++){
		for(j=0;j<16;j++){
			vram[i][j]=0;
		}
	}
	
	//数据结构复位
	ptrHead = 0;
	ptrTail = 0;
	length = 0;
	//计数器复位
	interruptCounter=0;
	refreshCounter=0;
	//关闭彩灯
	colorfulLight=0;
	//分数归零
	point=0;
	//关闭蜂鸣器
	isBuzzing=0;
	
	//生成小蛇,初始长度为三,位于左上角,蛇头向东
	AddHead(0x00);
	AddHead(0x01);
	AddHead(0x02);
	orientation = TOWARD_EAST;
	//状态位初始化
	forwardDirection = GO_STRAIGHT;
	state = ALIVE;
	
	//放置食物
	SetFood();
	//刷新点阵
	refresh();
}

成品特写

正面

[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)_第9张图片

焊接面

[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)_第10张图片

初始画面

[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)_第11张图片

结束时

[记录] 基于STC89C52RC的贪吃蛇三色游戏机设计(内含点阵驱动、数码管驱动详解)_第12张图片

有问题欢迎提出

你可能感兴趣的:(嵌入式,嵌入式,单片机,游戏)