项目介绍
项目实现了在M5StickC Plus平台上移植俄罗斯方块游戏,实现初始化、制作方块、屏幕绘制、判断游戏结束、移动操作扫描、获取下一个方块位置、消除行等函数。通过左右翻转开发板实现俄罗斯方块的左右移动,通过按键实现方块的旋转。游戏遵循俄罗斯方块的游戏规则,同时实行得分制和等级制,完美还原了俄罗斯方块的经典游戏体验。
开发板介绍
本次项目使用的平台使用的是M5StickC Plus。M5StickC Plus 是M5StickC的大屏幕版本,主控采用ESP32-PICO-D4模组,具备蓝牙4.2与WIFI功能,小巧的机身内部集成了丰富的硬件资源,如红外、RTC、麦克风、LED、IMU、按键、蜂鸣器、PMU等,在保留原有M5StickC功能的基础上加入了无源蜂鸣器,同时屏幕尺寸升级到1.14寸、135*240分辨率的TFT屏幕,相较之前的0.96寸屏幕增加18.7%的显示面积,电池容量达到120mAh,接口同样支持HAT与Unit系列产品。
功能实现
我完成的是任务3:使用姿态传感器完成体感游戏手柄,并设计完成一个游戏,例如俄罗斯方块、左右晃动手柄使方块左右移动。
按键操作
在本项目中记连接在GPIO37脚上的按键是主键,连接在GPIO39脚上的按键时副键。按下复位键后,M5StickC Plus开机,在开机画面下按下主键进入游戏模式,在游戏模式下按下副键开始游戏,游戏结束后再次按下副键重新开始游戏。
代码实现框图
板卡设置
本次arduino开发环境搭建,我使用的是vscode编辑器代替arduino IDE。先在电脑上安装好arduino IDE,再在vscode的插件管理中搜索“arduino”,下载arduino插件。后在vscode的arduino configuration页面加入arduino安装目录,以配置好工具链。
Arduino基础配置中没有自带M5StickC Plus板卡,所以要先在arduino IDE -> 工具 -> 开发板 -> 开发板管理器中搜索M5StickC Plus板卡并下载添加。
在VScode的Arduino Board Configuration 界面搜索M5StickC Plus并添加,将M5StickC Plus连接至电脑,并选择好串口。
烧录
使用VScode打开工程,连接开发板,点击烧录按钮即可实现程序的编译验证(Verify)和烧录上传(Upload)。
代码分析
setup()入口函数下,首先使能按键引脚,初始化M5StickC Plus库,初始化I2C,初始化MPU6886,在开机页面上显示硬禾学堂、乐鑫和M5STACK的logo,增加高级感。使用make_block函数制作方块以及设置其12x12个像素的颜色。设置开始时第一个方块的位置和方向,并绘制在屏幕上。
// 初始化函数
void setup(void)
{
pinMode(Primary_Key, INPUT_PULLUP); // 使能左移按键
pinMode(Secondary_Key, INPUT_PULLUP); // 使能右移按键
M5.begin(); // M5StickC-Plus 初始化
Wire.begin(32, 33); // 使能I2C管脚
M5.Imu.Init(); // MPU6886初始化
M5.Lcd.setSwapBytes(true); // 开启显示
M5.Lcd.pushImage(0, 0, 135, 240, pic); // 显示开机画面
while (!(digitalRead(Primary_Key) == 0 && digitalRead(Secondary_Key) == 1)); // 按下主键进入游戏
M5.Lcd.fillScreen(BLACK); // 背景刷新黑色
M5.Lcd.drawLine(11, 19, 122, 19, WHITE); // 游戏范围框线(上)
M5.Lcd.drawLine(11, 19, 11, 240, WHITE); // 游戏范围框线(左)
M5.Lcd.drawLine(122, 19, 122, 240, WHITE); // 游戏范围框线(右)
M5.Lcd.drawString("SCORE:" + String(score), 14, 8, 1); // 显示分数
M5.Lcd.drawString("LVL:" + String(lvl), 88, 8, 1); // 显示等级
// 制作方块以及设置其12x12个像素的颜色
make_block(0, BLACK);
make_block(1, RED); // DDDD
make_block(2, PURPLE); // DD,DD
make_block(3, BLUE); // D__,DDD
make_block(4, GREEN); // DD_,_DD
make_block(5, YELLOW); // __D,DDD
make_block(6, NAVY); // _DD,DD_
make_block(7, PINK); // _D_,DDD
PutStartPos(); // 开始位置
for (int i = 0; i < 4; ++i)
screen[pos.X + block.square[rot][i].X][pos.Y + block.square[rot][i].Y] = block.color;
Draw(); // 画方块
}
绘制不同种类的方块
根据方块坐标将七种类型的方块按4种旋转方向,放在一个方块结构体数组中。方块结构体包括了每一个方块的坐标、旋转后的类型数量和颜色。
// 坐标结构体
struct Point
{
int X, Y;
};
// 方块结构体
struct Block
{
Point square[4][4];
int numRotate, color;
};
// 设置不同种类的方块
blocks[0] =
{{{{-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};
blocks[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};
blocks[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};
blocks[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};
blocks[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};
blocks[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};
blocks[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};
MPU6886角度和按键控制
M5stickC Plus自带一个MPU6886六轴IMU角度传感器,内置3轴加速计与3轴陀螺仪。而M5stickC Plus的MPU6886库中没有直接求横滚角、俯仰角和航向角的函数,因此,本项目通过getAccelData(&accX, &accY, &accZ)函数获取角加速度,当X轴加速度accX满足-1 < accX < 1时,横滚角roll = arcsin(-accX) * 57.295。通过阈值控制算法判断当横滚角大于等于7度时,执行一次右移操作,当横滚角小于等于-7度时,执行一次左移操作。但在实验的过程中发现,左右移动的速度太快,因此,使用计数变量判断触发十次执行一次,减缓移动速度。
// MPU6886角度解算和按键控制
bool KeyScan()
{
static int delay_count_left = 0;
static int delay_count_right = 0;
/*------------- 获取角度------------- */
M5.Imu.getAccelData(&accX, &accY, &accZ);
if ((accX < 1) && (accX > -1))
{
theta = asin(-accX) * 57.295;
}
if (accZ != 0)
{
phi = atan(accY / accZ) * 57.295;
}
/*-----------------------------------*/
if ((int)theta <= -7)
{
delay_count_right = 0;
delay_count_left++;
if (delay_count_left % 10 == 0) // 减慢左移动速度
{
delay_count_left = 0;
ClearKeys();
but_LEFT = true;
return true;
}
}
if ((int)theta >= 7)
{
delay_count_left = 0;
delay_count_right++;
if (delay_count_right % 10 == 0) // 减慢右移动速度
{
delay_count_right = 0;
ClearKeys();
but_RIGHT = true;
return true;
}
}
if (digitalRead(Secondary_Key) == 0 && digitalRead(Primary_Key) == 1) // 副键开始游戏
{
if (pom1 == 0)
{
pom1 = 1;
ClearKeys();
but_begin = true;
return true;
}
}
else
{
pom1 = 0;
}
if (digitalRead(Primary_Key) == 0 && digitalRead(Secondary_Key) == 1) // 主键旋转方块
{
if (pom2 == 0)
{
pom2 = 1;
ClearKeys();
Rotary_Key = true;
return true;
}
}
else
{
pom2 = 0;
}
return false;
}
屏幕刷新
调用RefreshScreen(Point next_pos, int next_rot)函数实现屏幕的刷新,判断当方块下降达到底端时,生成下一个方块;当存在满行时消除满行;当方块叠加到达屏幕顶端时,结束游戏。
// 更新屏幕
void RefreshScreen(Point next_pos, int next_rot)
{
if (!started) // 未开始游戏则退出
return;
Point next_boxs[4];
for (int i = 0; i < 4; ++i)
screen[pos.X + block.box[rotate][i].X][pos.Y + block.box[rotate][i].Y] = 0;
if (GetBox(block, next_pos, next_rot, next_boxs))
{
for (int i = 0; i < 4; ++i)
{
screen[next_boxs[i].X][next_boxs[i].Y] = block.color;
}
pos = next_pos;
rotate = next_rot;
}
else
{
for (int i = 0; i < 4; ++i)
screen[pos.X + block.box[rotate][i].X][pos.Y + block.box[rotate][i].Y] = block.color;
if (next_pos.Y == pos.Y + 1)
{
DeleteLine();
Initial_State();
if (!GetBox(block, pos, rotate, next_boxs)) // 到达顶端则游戏结束
{
for (int i = 0; i < 4; ++i)
screen[pos.X + block.box[rotate][i].X][pos.Y + block.box[rotate][i].Y] = block.color;
GameOver();
}
}
}
Draw();
}
设置方块开始位置及其方向
在游戏开始时调用Initial_State()函数,可以设置第一个方块的初始位置和方向,起始位置为最上端从左到右第四个正方形块的位置,方块的类型通过random(7)随机生成数字从七种形状中选出一种,旋转方向也是随机选出。
// 设置初始位置以及方块形状、坐标、旋转方向
void Initial_State()
{
game_speed = 20; // 设置方块落下速度
pos.X = 4;
pos.Y = 1;
block = blocks[random(7)]; // 随机方块种类
rotate = random(block.numRotate); // 随机方块旋转方向
}
满行删除
根据俄罗斯方块的游戏规则,如果某一行排列成了完整的一行,则这一行消除,分数 + 1,每当分数达到5分时,等级 + 1,同时方块下落速度加快。
// 如果存在某一行没有空出的方格,则分数+1,此行删除
void DeleteLine()
{
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) // 如果存在某一行没有空出的方格,则分数+1,此行删除
{
score++;
if (score % 5 == 0) // 分数达到5
{
lvl++; // 等级+1
game_speed = game_speed - 4; // 方块落下速度-4
M5.Lcd.drawString("LVL:" + String(lvl), 88, 4, 2);
}
M5.Lcd.drawString("SCORE:" + String(score), 14, 4, 2);
M5.Lcd.drawLine(11, 19, 122, 19, RED); // 游戏范围框线(上)***
for (int k = j; k >= 1; --k)
{
for (int i = 0; i < Width; ++i)
{
screen[i][k] = screen[i][k - 1];
}
}
}
}
}
项目总结
在本次暑假的项目中,我实现了一直以来的想实现的功能——在嵌入式开发板上移植一款复古游戏。因此,本次通过M5StickC Plus平台实现了一款俄罗斯方块游戏的移植。在移植过程中也遇到了很多的问题,例如方块各种形态的绘制、满行时消除一行并向下覆盖一行和多重数组元素的调用等算法较为复杂。在多方借鉴和算法参考下,最终完成了这个项目。在实现过程中,我学到了如何通过像素操作LCD屏幕实现游戏的移植,提升了我的嵌入式开发能力和水平。
结果展示