1.项目需求
- 设计或移植一款经典的游戏
- 可以通过LCD屏显示
- 可以通过按键和四向摇杆控制游戏的动作
- 在游戏中要通过蜂鸣器播放背景音乐
2.完成的功能及达到的性能
(1)完成设计(移植)经典游戏《俄罗斯方块》
- 设计平台:Arduino
- 编写语言:C
- 编写环境:Win11
- 硬件平台:RP2040
(2)完成通过LCD屏显示《俄罗斯方块》游戏界面
- 使用代码库:TFT_eSPI
- 总线协议:四线SPI(时钟SCLK,片选CS,从入主出MOSI,从出主入MISO)
- 硬件平台:ST7789(240*240)
(3)完成通过按键和四向摇杆控制《俄罗斯方块》游戏的动作
- 实现逻辑:电平检测、模拟量读取
- 使用函数:digitalRead( ); analogRead( );
- 硬件平台:Key按键、FJ08K四向摇杆
(4)完成在游戏中使用蜂鸣器播放《俄罗斯方块》游戏的背景音乐
- 音乐选取:《Коробейники(货郎)》
- 使用函数:tone( ); noTone( );
- 硬件平台:Buzzer蜂鸣器
3.实现思路
- 编写(移植)《俄罗斯方块》游戏的运行逻辑;
- 依托Arduino提供的TFT_eSPI库编写LCD屏幕显示界面;
- 编写《俄罗斯方块》游戏的背景音乐;
- 依托RP2040游戏机平台进行软硬件逻辑结合;
4.实现过程
(1)程序框图
(2)俄罗斯方块逻辑编写
(a)方块生成
这里运用了bool来判断是否要生成新的方块组合。代码如下:
bool GetSquares(Block block, Point pos, int rot, Point* squares) {
bool overlap = false;
for (int i = 0; i < 4; ++i) {
Point p;
p.X = pos.X + block.square[rot][i].X;
p.Y = pos.Y + block.square[rot][i].Y;
overlap |= p.X < 0 || p.X >= Width || p.Y < 0 || p.Y >= Height || screen[p.X][p.Y] != 0;
squares[i] = p;
}
return !overlap;
}
(b)方块类型信息存储
构造结构体,存储颜色等信息,把方块用矩阵来表示,再用数组来将信息存储。代码如下:
struct Block {
Point square[4][4];
int numRotate;
int color;
};
Block block;
Block blocks[7] = {
{{{{ -1, 0}, {0, 0}, {1, 0}, {2, 0}}, {{0, -1}, {0, 0}, {0, 1}, {0, 2}}, {{0, 0}, {0, 0}, {0, 0}, {0, 0}}, {{0, 0}, {0, 0}, {0, 0}, {0, 0}}}, 2, 1},
{{{{0, -1}, {1, -1}, {0, 0}, {1, 0}}, {{0, 0}, {0, 0}, {0, 0}, {0, 0}}, {{0, 0}, {0, 0}, {0, 0}, {0, 0}}, {{0, 0}, {0, 0}, {0, 0}, {0, 0}}}, 1, 2},
{{{{ -1, -1}, { -1, 0}, {0, 0}, {1, 0}}, {{ -1, 1}, {0, 1}, {0, 0}, {0, -1}}, {{ -1, 0}, {0, 0}, {1, 0}, {1, 1}}, {{1, -1}, {0, -1}, {0, 0}, {0, 1}}}, 4, 3},
{{{{ -1, 0}, {0, 0}, {0, 1}, {1, 1}}, {{0, -1}, {0, 0}, { -1, 0}, { -1, 1}}, {{0, 0}, {0, 0}, {0, 0}, {0, 0}}, {{0, 0}, {0, 0}, {0, 0}, {0, 0}}}, 2, 4},
{{{{ -1, 0}, {0, 0}, {1, 0}, {1, -1}}, {{ -1, -1}, {0, -1}, {0, 0}, {0, 1}}, {{ -1, 1}, { -1, 0}, {0, 0}, {1, 0}}, {{0, -1}, {0, 0}, {0, 1}, {1, 1}}}, 4, 5},
{{{{ -1, 1}, {0, 1}, {0, 0}, {1, 0}}, {{0, -1}, {0, 0}, {1, 0}, {1, 1}}, {{0, 0}, {0, 0}, {0, 0}, {0, 0}}, {{0, 0}, {0, 0}, {0, 0}, {0, 0}}}, 2, 6},
{{{{ -1, 0}, {0, 0}, {1, 0}, {0, -1}}, {{0, -1}, {0, 0}, {0, 1}, { -1, 0}}, {{ -1, 0}, {0, 0}, {1, 0}, {0, 1}}, {{0, -1}, {0, 0}, {0, 1}, {1, 0}}}, 4, 7}
};
(c)移动和旋转问题
《俄罗斯方块》游戏最有魅力的点在于其移动和旋转的机制。GetNextPosRot()函数结合玩家在硬件操作反馈的结果,来判断方块组合该怎么移动。这里使用了数个if判断分支来实现这一功能。代码如下:
void GetNextPosRot(Point* pnext_pos, int* pnext_rot) {
bool received = KeyPadLoop();
if (digitalRead(SW)==0) started = true;
if (!started) return;
pnext_pos->X = pos.X;
pnext_pos->Y = pos.Y;
if ((fall_cnt = (fall_cnt + 1) % 10) == 0) pnext_pos->Y += 1;
else if (received) {
if (Button_LEFT) {
Button_LEFT = false;
pnext_pos->X -= 1;
}
else if(Button_RIGHT) {
Button_RIGHT = false;
pnext_pos->X += 1;
}
else if(Button_DOWN) {
Button_DOWN = false;
pnext_pos->Y += 1;
}
else if(Button_B) {
Button_B = false;
*pnext_rot = (*pnext_rot + 1)%block.numRotate;
}
else if(Button_A) {
Button_A = false;
*pnext_rot = (*pnext_rot + block.numRotate - 1)%block.numRotate;
}
}
}
(d)怎么存储和更新积累的方块
利用for循环,不断扫描screen数组内数值,以判断下落的方块是否与底部接触,接触则更新数组并生成新的方块下落。当无法生成新的方块时,游戏结束。代码如下:
void ReviseScreen(Point next_pos, int next_rot) {
if (!started) return;
Point next_squares[4];
for (int i = 0; i < 4; ++i) screen[pos.X + block.square[rot][i].X][pos.Y + block.square[rot][i].Y] = 0;
if (GetSquares(block, next_pos, next_rot, next_squares)) {
for (int i = 0; i < 4; ++i) screen[next_squares[i].X][next_squares[i].Y] = block.color;
pos = next_pos;
rot = next_rot;
}
else {
for (int i = 0; i < 4; ++i) screen[pos.X + block.square[rot][i].X][pos.Y + block.square[rot][i].Y] = block.color;
if (next_pos.Y == pos.Y + 1) {
DeleteLine();
PutStartPos();
if (!GetSquares(block, pos, rot, next_squares)) {
for (int i = 0; i < 4; ++i) screen[pos.X + block.square[rot][i].X][pos.Y + block.square[rot][i].Y] = block.color;
GameOver();
}
}
}
Draw();
}
(e)怎样让障碍物满行后自动消除
从底部开始判断是否有空的一行方块,如果没有空白的就让这一行方块存储上一行方块的信息,以此类推,这样就可以达到消行的效果了。代码如下:
void DeleteLine() {
int DeleteCount=0;
for (int j = 0; j < Height; ++j) {
bool Delete = true;
for (int i = 0; i < Width; ++i) if (screen[i][j] == 0) Delete = false;
if (Delete)
{
for (int k = j; k >= 1; --k)
for (int i = 0; i < Width; ++i)
screen[i][k] = screen[i][k - 1];
DeleteCount++;
}
}
score+=DeleteCount*DeleteCount;
}
4.3 LCD显示部分
(1)SPI总线协议
SPI(Serial Peripheral Interface - 同步外设接口)总线是一种用于短距离通信(主要是嵌入式系统中)的同步串行通信接口规范,虽然没有正式的国际标准,但这种接口协议由Motorola发明迄今经过很多厂商的支持,已经成了一种事实标准,被广泛用于各种MCU处理器中,同传感器,串行ADC、DAC、存储器、SD卡以及LCD等进行数据连接。由于没有统一的国际标准,SPI出现了很多不同的协议选项,例如不同的Word大小;每个设备都有自己的协议定义,包括是否支持命令;有些设备只发送,其它的则只是接收;有的片选是高有效,有的则是低有效;有的协议先发送最低位。
本硬件平台使用的是四线SPI(时钟SCLK,片选CS,从入主出MOSI,从出主入MISO)的ST7789LCD屏幕。
(2)TFT_eSPI库
TFT_eSPI库适用于 32 位处理器的 Arduino IDE 兼容图形和字体库。该库针对 32 位处理器,针对 STM32、ESP8266 和 ESP32 类型进行了性能优化。该库可以使用 Arduino IDE 的库管理器加载。直接内存访问 (DMA) 可与 ESP32、RP2040 和 STM32 处理器一起使用,以提高渲染性能。
为以下处理器合并了优化的驱动程序:
- RP2040,例如树莓派 Pico
- ESP32 和 ESP32-S2
- ESP8266
- STM32F1xx、STM32F2xx、STM32F4xx、STM32F767(建议使用更高 RAM 的处理器)
既然有这么好用的库,又何必自己去造轮子呢?在理解的基础上运用好它就行了。
(3)摇杆控制部分
FJ08K摇杆为一个双向的电阻器,随着摇杆方向不同,抽头的阻值随着变化。本模块使用 3.3V 供电, VRx,VRy (X、Y 轴)为模拟输入信号,连接到RP2040相应的IO口。VRx,VRy 的值:小于1000为左,大于3000为右;小于300为上,大于3000为下。其代码如下所示:
if (millis()-ButtonElsp<100) {return false;}
ButtonElsp=millis();
ClearKeys();
if(analogRead(VRX)<1000) {
Button_LEFT=true;
return true; }
if(analogRead(VRX)>3000) {
Button_RIGHT=true;
return true;}
if(analogRead(VRY)<300) {
Button_UP=true;
return true;}
if(analogRead(VRY)>3000) {
Button_DOWN=true;
return true;}
(4)背景音乐部分
《俄罗斯方块》作为一款经典游戏,其游戏背景音乐也是经典中的经典了。在本次任务中,我选用的是老版本《俄罗斯方块》的背景音乐:《Коробейники(货郎)》。
这是一首俄罗斯著名民歌,内容是一位年轻的货郎与一位少女顾客之间的对话,两人在交易中开始相互争执、讨价还价,后来渐生情窦,擦出了爱情的火花。歌曲的歌词来源于俄国诗人尼古拉·阿列克塞耶维奇·涅克拉索夫(Nikolay Alexeyevich Nekrasov,1821-1877年)的同名诗《货郎》的开头6段。这首诗于1861年刊登在杂志Sovremennik上,之后这首歌很快成为俄罗斯著名的民歌。
由于Arduino封装的代码库以及函数非常丰富和实用,所以我也就不重复造轮子了,在参考了网上的例程后,直接利Arduino中自带的tone( ); noTone( );两个函数实现了利用蜂鸣器演奏这首曲子。下面是主体函数部分:
// from https://blog.csdn.net/qq_35898865/article/details/105791890
void tetris_Music()
{
//注意,该数组是notes (notes +期限)数量的两倍
for (int thisNote = 0; thisNote < notes * 2; thisNote = thisNote + 2)
{
//计算每个音符的持续时间
divider = melody[thisNote + 1];
if (divider > 0)
{
noteDuration = (wholenote) / divider;
}
else if (divider < 0)
{
noteDuration = (wholenote) / abs(divider); //负数,是加点的音符
noteDuration *= 1.5; //点状音符的持续时间为 二分音符 + 四分音符
}
//为确保音乐效果,防止出现连音,这里只用九层的时间放音,剩下的时间静音
tone(buzzer, melody[thisNote], noteDuration*0.9);
//在播放下一个音符之前,等待指定的持续时间。
delay(noteDuration);
//在下一个音符之前停止波形生成。
noTone(buzzer);
}
}
5.遇到的主要难题
(1)方块下落速度与更新速度缓慢
在没有使用TFT_eSPI库前,方块下落速度与更新速度缓慢问题几乎无解。一方面是因为新ST7789固件与以前版本有些许出入,另一方面是对SPI总线没有进行有利的使用。
但是后面在汤半泛(微信名)大佬贡献的TFT_eSPI库加持下,此问题得到了圆满的解决。
TFT_eSPI库对RP2040支持的改动如下:
-
在User_Setup.h文件中,取消或更改下列代码注释或内容
#define ST7789_DRIVER #define TFT_RGB_ORDER TFT_RGB #define TFT_WIDTH 240 #define TFT_HEIGHT 240 #define TFT_CS -1 //在本硬件平台上,CS接地,始终为低电平 #define TFT_DC 1 #define TFT_RST 0 #define TFT_MOSI 3 #define TFT_SCLK 2
(2)计分系统的编写
理论上来说,计分系统的编写并不困难,在代码逻辑上也已经实现自洽,但是问题出现在LCD显示部分。当最下面一层方块消除后,计数确实发生了变化,这在通过串口调试时也是正常的。可在LCD显示上就是不正常的——其始终无法消除初始的“0”值。
这让我十分的困惑。一开始我以为是代码逻辑的问题,但是当代码运行在esp32上时,其又是正常的;后来我怀疑是Lcd驱动库的问题,但是换了几个函数之后还是无济于事。最后没有办法,我就暂时搁置了计分系统的编写。
(3)方块质感的体现
在传统《俄罗斯方块》游戏中,其方块都是极富质感的,有砖石般的晶体感。那怎么在单片机上去实现这样的效果呢?在参考了网上的案例以及TFT_eSPI库后,这个问题得到了解决——将图案取模后放入方块即可。
6.未来规划
该项目已经成功实现了编写(移植)《俄罗斯方块》游戏的功能,并达到了预期指标。然而其还有许多可以提升与扩展的地方:
- 可以使背景图像更加美观。目前版本由于取模软件的原因,游戏背景无法插入图片,后续有时间会进行改进。
- 可以添加一个游戏开始菜单。目前版本由于技术原因(不是),无法实现游戏菜单界面,后续会跟进学习。
- 可以实现游戏音乐打开或者关闭。这个实现难度不大,但是单独实现不太美观,待添加游戏开始菜单后一并加入。
- 可以实现计分系统。目前版本由于游戏背景干扰以及技术原因,无法进行计分操作,后续会跟进的。