好久没更了。这两天疫情闹得厉害,全国各地几乎都延迟了开工开学的日期,在家里处于待业状态的堂姐借了我的电脑在家里办公,这倒是搞得我没电脑可以玩了,这篇更新应该在2月7号之后才能再发。
本篇带来的是我大三短学期MCU课的课程设计《Little Plane》,搭载在德研电科的DY-FFTB6638 V3.0硬件实验箱。项目的游戏类似于早年的诺基亚手机(?)中的经典游戏《像素小鸟》,大致功能实现起来并不复杂,主要复杂的部分在于内存的使用和运行流畅度等细节方面的考虑,在验收的头晚我们五个人(Z、Y、Z、Y、Z)还在食堂挑灯夜战对内存使用进行压缩。下面基于课程设计报告对本项目内容进行叙述:
一、开发平台及辅助工具等
硬件平台:DY-FFTB6638 V3.0硬件实验箱
硬件主控:MSP430F6638_FFTB
编译器:Code Composer Studio 9.1.0
编程语言:C语言
二、硬件模块种类
项目本身的原则是尽可能得利用所提供的模块进行游戏的设计和制作,熟悉单片机本身和其外设的用法。本项目所使用的硬件模块及功能如下:
(1)TFT-LCD显示游戏界面,并更新当前操作可视化显示
(2)数码管显示游戏计分
(3)键盘选择玩家样式,开始和继续
(4)摇杆(按键)实现玩家控制
(5)Buzzer和Speaker实现提示音和背景音乐
(6)时钟、中断、Flash实现基础功能构建,并实现相应的数据保存
三、游戏功能介绍
1) 具有完整的LCD游戏界面。开始界面展示游戏logo、提示文字提示按键即开始;机型选择界面展示可选机型,提示选择按键对应飞机名称;游戏操控界面,连续显示当前障碍及分机位置,左上角显示血量,右上角显示最高分,响应按键控制以及障碍生成实时更新界面;游戏结束界面,背景停止显示“GAME OVER”图案,文字提示按键可继续;
2) 随机生成障碍,并且随时间提升难度。每次初始化游戏产生障碍位置不同,随机产生,并且在计分达到500以后将难度提升一档;
3) 分数计数显示于数码管,并且存储最高分。每次开始游戏分数初始为0,随游戏时间递增计数,游戏结束计数停止,在数码管上显示当前分数。并且判断最高分,破纪录时将新产生的最高分存储在flash里,以便刷新显示;
4) 飞机位置模拟重力下降,读取按键控制上升加速度。分机本身符合自由落体原则,具有向下的重力,每次读到按键输入则模拟加油,反向给上升加速度由此控制飞机姿态;
5) 碰撞检测,碰撞掉血。每次游戏初始5格血量,检测到每次碰撞减一(做延时处理),血量耗尽“GAME OVER”;
6) 键盘和按键读取。按游戏步骤进行键盘读取,判断键入值进行相应操作;按键读取在飞机飞行时检测,读到响应为上升加速度给予;
7) Buzzer播放提示音,Speaker播放背景音。每次碰撞Buzzer输出提示音“哔~”,背景音于游戏开始到结束一直播放,乐曲为超级马里奥经典音乐片段。
四、整体流程图

五、功能实现
5.1 图像压缩
一个精美的界面要求设计者合理地利用色彩及几何图形进行设计,而本项目搭载在实验箱上,直接利用编程进行设计不太现实,因此大多采用的是将图像保存在数组中,通过读取像素信息进行绘制。实验箱中LCD尺寸为240x320,采用16位RGB,则一幅完整的图像所需内存大小为240x320x2 = 153600B = 150KB,一般来说,微控的ROM大小小于1M,而我们的设计中不光包含主界面,还有血量、三种玩家图形及游戏结束界面的图形设计,因此极易发生内存不够用的情况(事实上就是这个原因导致CCS中编译不能通过进而逼迫我们进行了图像压缩的设计)。以下为具体实现过程:
主界面中游戏名称设计如下图:

每个字母的大小为40×25,均由四种颜色构成,除去共有的黑白两色,还有深主色和浅主色两种颜色。将每个字母定义为一个数组。以绘制字母P为例:



5.2 绘制开始界面
开始界面如下图·
其中主要难点在于屏幕中心的“LITTLE PLANE”字符串绘制。将图像信息压缩保存为上述数组之后,解决了单片机内存不足的问题,但是绘图时就需要对图像信息进行解码。由于丢失了色彩信息,所以需要单独引入颜色数组。,这里用于默认0为白色1为黑色,故没有给出这两个色彩的信息,而只保存深浅红色信息:

void LetterDraw(int x,int y, const uint8_t img[],const uint16_t imgcolor[])
{
int i, j, bit;
uint16_t color;
for (i = 0; i < 25; i++)
{
for (j = 0; j < 10; j++)
{
uint8_t Num = *(img + i * 10 + j);
for (bit = 0; bit < 4; bit++)
{
switch ((Num & 0xC0))
{
case 0x00:
color = WHITE;
break;
case 0x40:
color = BLACK ;
break;
case 0x80:
color = imgcolor[0];
break;
default:
color = imgcolor[1];
break;
}
if(color != BLACK) LCD_TFT_DrawPoint(x + ((i * 10 + j) * 4 + bit) / 25, y + ((i * 10 + j) * 4 + bit) % 25, color);
Num = Num << 2;
}
}
}
}
主界面显示完整程序如下:
void MenuDraw()
{
int stax = 50;
int stay = 74; //字母的起始位置
int space = 32;
LCD_TFT_Clear(BLACK);
//依次画出LITTLE PLANE
LetterDraw(stax,stay,L,L_color1);
LetterDraw(stax,stay+25,I,I_color);
LetterDraw(stax,stay+50,T,T_color0);
LetterDraw(stax,stay + 50 + space,T,T_color1);
LetterDraw(stax,stay + 50 + 2*space,L,L_color0);
LetterDraw(stax,stay + 50 + 3*space,E,E_color0);
LetterDraw(stax + 53,stay + 10 ,P,P_color);
LetterDraw(stax + 53,stay + 10 + space,L,L_color2);
LetterDraw(stax + 53,stay + 10 + 2*space,A,A_color);
LetterDraw(stax + 53,stay + 10 + 3*space,N,N_color);
LetterDraw(stax + 53,stay + 10 + 4*space,E,E_color1);
//右上角显示制作时间与制作人
int i;
LCD_TFT_ShowString(5,130,"Release at 2019/9/11 by ZYZYZ.",FONT1206,WHITE,BLACK);
//按键检测,任意按键按下则跳出循环
while(1)
{
LCD_TFT_ShowString(173,76,"Press Anykey To Start!",FONT1608,YELLOW,BLACK);
i=Read_key();
if(i<16&&i>0)
{
while(Read_key()==i); //等待按键释放
break;
}
LCD_TFT_ShowString(173,76,"Press To Start!",FONT1608,YELLOW,BLACK);
}
}
在网络上并不容易找到直接的16位RGB颜色值,因此结合原理,自行编写了由R、G、B三个16位无符号整型转换为一个16位RBG颜色值程序,一并给出:
uint16_t Color(uint16_t R, uint16_t G, uint16_t B)
{
uint16_t color16_t = 0;;
R &= 0b0000000011111000;
G &= 0b0000000011111100;
B &= 0b0000000011111000;
color16_t |= R << 8;
color16_t |= G << 3;
color16_t |= B >> 3;
return color16_t;
}
5.3机型选择界面
这里我们小组中的其中三个人分别设计了一款自己的"像素小鸟",而实际只有一款和原游戏风格一致hhhh(我画的中间个最好看!)
其中每个机型的图像大小为20x15,补1位同样进行压缩处理得到20x4的图像数组,同样为四色,具体实现程序与开始界面绘制类似。飞机机型的选择用到了按键检测,具体实现函数如下:
void Choose()
{
//显示每种飞机的名称
LCD_TFT_Clear(BLACK);
PlaneDraw(80,20+30,20,15,paperplane,paper_color);
LCD_TFT_ShowString( 140,40,"1.P-plane",FONT1608,WHITE,BLACK);
PlaneDraw(80,20+130,20,15,rocket,rocket_color);
LCD_TFT_ShowString( 140,140,"2.Rocket",FONT1608,WHITE,BLACK);
PlaneDraw(80,20+230,20,15,bird,bird_color);
LCD_TFT_ShowString( 140,240,"3.Bird",FONT1608,WHITE,BLACK);
unsigned int i = 0;
while(1)
{
rand();//随机数保持生成
i=Read_key();
if(i<4&&i>0)
{
while(Read_key()==i); //等待按键释放
switch (i)
{
case 1:
plane.image = paperplane;
plane.image_color = paper_color;
break;
case 2:
plane.image = rocket;
plane.image_color = rocket_color;
break;
case 3:
plane.image = bird;
plane.image_color = bird_color;
break;
}
LCD_TFT_Clear(BLACK);
break;
}
}
}
其中PlaneDraw()即为机型绘制函数,plane.image保存最终选定的机型。实际画面如下:

5.4障碍生成模块
在经典游戏像素小鸟中,障碍是一个高低不同的管道构成,而实际我们实现这个游戏的时候采用的是凹凸不平的方块构成连绵不断的隧道这样的形式,事实上这不仅增加了游戏的难度,还增加了实现的难度。
在实验时,发现了一个很诡异的事——CCS中对于二维数组的编译通不过,这一点很奇怪,没办法我们采用了一维数组Backboard对背景板信息进行保存。先给出完整的障碍生成程序:
uint8_t Backboard[BackHeight * BackWidth]={0};//背景板
uint8_t BackboardOld[BackHeight * BackWidth]={0};//背景板
uint16_t Blankbeginline = BackHeight >> 1;
uint8_t generate_times = 0;
void Barrier_Generate()
{
uint16_t randnum = rand();
static uint8_t line = 1;
uint8_t row,col;
//更新背景板
for(col = line - 1;col > 0;--col)
{
for(row = 0;row < BackHeight; ++row)
{
Backboard[row + col*BackHeight]= Backboard[row + (col - 1)*BackHeight];
}
}
//产生随机空白方块
uint16_t BlankblocknumTop = limit_Num(0,Blankbeginline - randnum % (BackHeight >> 1), BackHeight - 1) ;//空白方块
uint16_t BlankblocknumDown = limit_Num(0,Blankbeginline + (randnum >> 2) % (BackHeight >> 1), BackHeight - 1);//空白方块
uint8_t Blanknum = 0;
uint8_t i ,level = 2;
if(plane.pscore > 100)
level =1;
if(BlankblocknumDown - BlankblocknumTop <= 2 + level)
{
for(i = BlankblocknumTop;i < BlankblocknumTop + 3 + level;++i)
{
Backboard[i]= 0;
}
}
else
{
for(row = 0;row < BackHeight; ++row)
{
if(row >= BlankblocknumTop && row <= BlankblocknumDown)
{
Backboard[row]= 0;
}
else
{
Backboard[row]= 1;//非空白区域背景置1
Blanknum++;
}
}
}
if( Blanknum == BackHeight)
{
for(i = Blankbeginline;i < BackHeight;++i)
{
Backboard[i]= 0;
}
}
else Blankbeginline = ( BlankblocknumTop+ BlankblocknumDown)>>1;
//更新面板
if(line < BackWidth) line++;//刚初始化时减少画图时间
}
每次进入该函数先将上一场的背景板利用两层循环进行复制更新,以使得背景板形成从右至左的移动效果。
为形成中间区域空白的效果,我们采用在上一列空白区域的中间行坐标作为基准,向上下两个方向产生随机空白格数,相加起来就是该列的无障碍区域,但是由于随机数的产生特性,在几行代码的时间间隔之内,产生的多个随机数数值可能会相同,这将会导致某一段时间内的障碍形状将相同,为了解决的这个问题,我们将每次产生的随机数进行右移两位的操作,从而产生了一个新的随机数,利用求余限幅的方法便得到了一列比较随机的障碍。
在有了一列障碍的基础上,我们要想得到整个屏幕联通路径,还需要经过一些特殊处理,这是因为虽然我们是在上一列空白区域的基础上产生障碍的,但是却只能保证相邻两列一定有至少一个方块的空隙,但是当涉及三列以之后,就说不准了,所以在这里我们设置了如果空隙太小则强制清除障碍。
另外一个问题就是随机数的产生实际是一个伪过程,也就是当给定一个固定的起始种子时,后续产生的随机数列均是相同的,如果不处理这个问题,将会导致每次启动游戏或者是重新开始游戏时,障碍产生情况会是一模一样的。为了解决这个问题,我尝试了很多方案。在利用rand()函数的基础上,这里列出一些我搜集的单片机伪随机数种子的产生方法:
(1)AD采集噪声,取其中几位作为随机数种子;
(2)利用用户交互信号时间作为随机数种子,比如按下某个按键的时间;
(3)在某个时刻保存一个随机数存入Flash,下次启动时读取该值作为随机数种子;
(4)利用时钟芯片获取当前时间作为随机种子
在本项目中采取的方法类似于第(2)种,通过在等待用户选择机型时保持随机数的产生,而由于用户选择开始游戏的时刻不同可以使得在第一次产生障碍时得到的随机数起点不同从而实现随机障碍。
另外,由于MSP430时钟频率在不超频的情况下最高只能达到20M HZ,因此对于240x320这样大小的LCD显示刷新频率是极慢的,因此在障碍绘制部分我们做了两个细节改进:
(1)将原本实心的障碍方块改为空心;
(2)检测每一列的前后时刻的障碍状态,当状态不同时才更新显存。
实现程序如下:
//指定左上角坐标画指定颜色和尺寸的方块
void Drawblock(int xnum,int ynum,int size,uint16_t color)
{
LCD_TFT_DrawRectangle(xnum * size,(BackWidth - 1 - ynum) * size ,(xnum + 1)* size - 1,(BackWidth - ynum)* size - 1,color);
}
//画背景板
void DrawBackboard()
{
uint16_t i , j;
for(j = 0;j < BackWidth;++j)
{
for(i = 0;i < BackHeight;++i)
{
//前后状态不一样时执行画图
if( Backboard[i + j*BackHeight] == 1&&BackboardOld[i + j*BackHeight] == 0)
Drawblock(i,j,Backblock,WHITE);
else if(Backboard[i + j*BackHeight] == 0&&BackboardOld[i + j*BackHeight] == 1)
Drawblock(i,j,Backblock,BLACK);
BackboardOld[i + j*BackHeight] = Backboard[i + j*BackHeight];//记录上一次的面板状态
}
}
}
5.5 玩家运动控制
在玩家的运动状态中引入了重力系统,由经典运动学,玩家的垂直运动速度受到向下的重力和向上的外力共同影响,其中外力由玩家按下按键产生。大致原理为:
(1)以向下为正方向,在100ms的定时中断中,利用公式Vn = Vp + g * Δt,其中Vn为当前速度,Vp为上一次计算出的速度,Δt为中断周期,g为重力加速度,为定值;
(2)当按下按键,由 Δv = a * Δt可知,将玩家的速度会减去一个固定值可模拟外力作用;
(3)由(1)、(2),玩家最终速度Vf = Vn + Δv ;
(4)由 x += Vf * Δt 即可算出玩家位置并绘制玩家图像。
在按键中断中实现外力作用:
#pragma vector=PORT2_VECTOR
__interrupt void port_2(void)
{
P4OUT ^= BIT5;//LED
plane.speed -= plane.impetus;//动力驱动改变速度
P2IFG &=~BIT6; // 清除中断标志位
}
合外力作用实现程序如下:
void Plane_mov(int gravity, double time, struct Plane* plane)
{
if(plane->x <= 0||plane->x >= 240 - 1 - plane->height)
{
// plane->speed = 0;//速度清零
if(plane->x <= 0)
{
plane->speed = 10;
P3IN &= ~BIT1;//推杆向上推状态清除
}
else plane->speed = -10;
}
else plane->speed += gravity * time; //v=v0+a*t
double height = plane->speed * time; //高度差h=v * ▲t
plane->x +=(int)height;
plane->x = limit_Num(0,plane->x,240 - 15 - 1);
}
5.6碰撞检测
本项目采取的碰撞检测实体均视为方块,相比于团队项目(2.1) -- 飞机躲避小游戏中的三角碰撞检测,实现起来更简单,故不做解释。实现程序如下:
//宽,高,右上角顶点坐标,返回是否碰撞
//最坏情况需要检测四个方块
uint8_t Boomcheck(uint8_t Width,uint8_t Height,int x,int y)
{
if(x <= 0||x >= 240 - 1 - Height)
{
return 1;//最上方或最下方
}
uint8_t col = (320 - y - 1) / Backblock;//列号,屏幕最右为0 //右上角对应列号
uint8_t col_width = (320 - y + Width - 1) / Backblock; //左上角对应列号
uint8_t row = x / Backblock;//行号,屏幕最上为0 //右下角对应行号
uint8_t row_height = (x + Height) / Backblock; //左下角对应行号
if(Backboard[col * BackHeight + row ] == 1) return 1;//右上角方块 //加1为了让游戏更简单
else if(Backboard[col_width * BackHeight + row] == 1) return 1;//左上角方块
else if(row != row_height && Backboard[col * BackHeight + row_height] == 1) return 1;//右下角方块
else if(col != col_width && Backboard[col_width * BackHeight + row_height] == 1) return 1;//左下角坐标
//else if(row + 1 >= BackHeight) return 1;//碰到最下方
//else if(row - 1 <= 0)return 1;
else return 0;
}
5.7扬声器音乐
本项目中,利用数模转换DAC产生不同频率的输出改变扬声器音调,通过控制改变时间实现节拍。

//节拍
const int song_long_table[Tick]={1, 2, 2, 1, 2, 4, 4, 2, 2, 2, 2, 2, 1, 2, 1, 2, 1, 2, 1, 2, 2, 1, 1,
3, 3, 3, 3, 2, 2, 1, 2, 1, 2, 1, 2, 1, 2, 2, 1, 1, 3, 2, 1, 1, 1, 2,
2, 1, 1, 2, 1, 1, 1, 2, 1, 1, 1, 2, 2, 2, 1, 4, 2, 1, 1, 1, 2, 2, 1,
1, 2, 1, 1, 1, 2, 3, 3, 8, 2, 1, 1, 1, 2, 2, 1, 1, 2, 1, 1, 1, 2, 1,
1, 1, 2, 2, 2, 1, 4, 2, 1, 1, 1, 2, 2, 1, 1, 2, 1, 1, 1, 2, 3, 3, 8
};
//音调
const int song_tone_table[Tick]={9, 9, 9, 7, 9, 11, 4, 7, 4, 2, 5, 6, 6, 5, 4, 9, 11, 12, 10, 11, 9, 7, 8,
6, 7, 4, 2, 5, 6, 6, 5, 4, 9, 11, 12, 10, 11, 9, 7, 8, 6, 21, 11, 10, 10, 9,
9, 4, 5, 7, 5, 7, 8, 21, 11, 10, 10, 9, 9, 14, 14, 14, 21, 11, 10, 10, 9, 9, 4,
5, 7, 5, 7, 8, 21, 9, 8, 7, 21, 11, 10, 10, 9, 9, 4, 5, 7, 5, 7, 8, 21, 11,
10, 10, 9, 9, 14, 14, 14, 21, 11, 10, 10, 9, 9, 4, 5, 7, 5, 7, 8, 21, 9, 8, 7
};
每个不同的音调对应输出不同频率,设置表key_table表示频率:
const int key_table[22] = {261,293,329,349,391,440,493,533,587,659,698,783,880,987,1046,1174,1318,1396,1567,1760,1975,0};
//计算定时器中断周期
for(j=0;j<21;j++)
{
period_table[j]=(int)(100000/key_table[j]);
}
period_table[21]=0; //休止符时置为0
对此模块选择时钟配置定时器,并使能中断:
DAC12_1CTL0 = DAC12IR + DAC12SREF_1 + DAC12AMP_5 + DAC12ENC + DAC12CALON+DAC12OPS;
P5DIR=BIT6;
P5OUT&=~BIT6;
TA1CCTL0 |= CCIE; // CCR0 interrupt enabled
TA1CCR0 = period_table[0];
TA1CTL = TASSEL_2 + MC_2 + TACLR; // SMCLK, upmode, clear TAR
TA1CTL |= TAIE;
在中断中操作读取当前乐谱,节拍通过k的增加来判断时长是否达到制定拍长,TA1CCR0设置计数从period_table[]读取相应音符,进行不同频率的输出,即可改变音调:
#pragma vector=TIMER1_A0_VECTOR
__interrupt void TIMER1_A0_ISR(void)
{
DAC12_1DAT=*sin_data_pr++;
if (sin_data_pr >= &sin_table[10])
{
sin_data_pr = &sin_table[0];
}
DAC12_1DAT &= 0xFFF; // Modulo 4096
TA1CCR0 += period_table[song_tone_table[key]];
}
#pragma vector=TIMER1_A1_VECTOR
__interrupt void TIMER1_A1_ISR(void)
{
static int k,m,n;;
switch(__even_in_range(TA1IV,14))
{
case 0: break; // No interrupt
case 2: break; // CCR1 not used
case 4: break; // CCR2 not used
case 6: break; // reserved
case 8: break; // reserved
case 10: break; // reserved
case 12: break; // reserved
case 14:
{
if(k++==8*song_long_table[m])
{
k=0;
if(++m==Tick)
m=0;
DAC12_1DAT=0x000;
TA1CCTL0 &=~ CCIE;
if(++n==1)
{
n=0;
TA1CCTL0 = CCIE;
TA1CCR0 = period_table[song_tone_table[key++]];
if(key==Tick)
key=0;
}
}
break;
}
default: break;
}
}
5.8分数记录
分数更新于100ms定时中断中,当玩家分数超过当前最高分时,会将此时的玩家的分数保存于Flash中,其中写入Flash程序如下:
void Flashwrite(uint32_t value)
{
__disable_interrupt(); // 擦除前,最好关闭全局中断
while(FCTL3 & BUSY); // 判断是否处于忙碌状态
FCTL3 = FWKEY; // 清除LOCK标志
FCTL1 = FWKEY + ERASE; // 置位ERASE位,选择段擦除
*FLASH_ptrD = 0; // 空写操作,地址可以为段范围的任意值
FCTL1 = FWKEY + BLKWRT; // 写允许,写长字
*FLASH_ptrD = value; // 写FLASH
while(FCTL3 & BUSY); // 判断是否处于忙碌状态
FCTL1 = FWKEY; // 清除WRT位
FCTL3 = FWKEY + LOCK; // 置位LOCK标志
// __enable_interrupt();
}
六、展示
最终提交的展示视频也放在B站了(其实有个我们一边笑一边玩的视频hhhh),视频链接:https://www.bilibili.com/video/av87724367,下面是总体连线图:

七、总结
本项目比较简单,但是在一些细节的处理上比较磨人,前后花了我们5个人一个周的课余时间,由于比较仓促,至今还存在不少的Bug(比如穿墙而过hhhh)。这次项目是上大学以来第一次感受到团队的氛围,整个过程大家各司其职,多数是在食堂一边码代码查Bug,一边闲聊嘻嘻哈哈,很轻松很愉快,最后验收的时候也被夸奖我们的界面做的很好看hhhhhh
下一篇是微机系统课程设计。