1. 项目概述:在MCU上重温经典
俄罗斯方块,这个诞生于上世纪80年代的经典游戏,几乎刻在了每一位电子爱好者的DNA里。它规则简单,却蕴含着无穷的策略与乐趣。今天,我们不做PC版,也不做手机App,而是要把它“塞进”一块小小的STM32F103开发板里。我手头这块是正点原子的“指南者”开发板,它搭载的STM32F103VET6芯片,拥有512KB的Flash和64KB的RAM,性能对于实现一个基础版的俄罗斯方块来说,绰绰有余。
这个项目的核心价值,远不止于“复刻一个游戏”那么简单。它是一次对嵌入式系统软硬件综合能力的绝佳演练。你需要驱动LCD屏幕来绘制图形,管理矩阵键盘或独立按键来接收玩家输入,利用定时器产生精确的游戏时钟节拍,还要设计高效的数据结构和算法来管理不断下落的方块。整个过程,你会深刻理解到在资源受限的微控制器(MCU)环境中,如何平衡功能、性能和代码可维护性。无论你是刚学完STM32基础外设的新手,想找一个综合项目练手,还是有一定经验的开发者,希望深入理解实时系统与游戏逻辑的结合,这个项目都能让你收获满满。
2. 整体设计与核心思路拆解
在STM32上做游戏,和你在电脑上写程序有本质区别。这里没有操作系统替你管理任务和内存,一切都需要你亲手安排。我的设计思路是围绕“实时性”和“资源管理”两个核心展开。
2.1 系统架构与模块划分
首先,我把整个游戏系统分解成几个松耦合的模块,这是保证代码清晰、易于调试的关键。
- 硬件驱动层:这是最底层,直接与STM32的片上外设打交道。包括LCD(我用的板子是TFT-LCD,驱动IC是ILI9341)的初始化、画点、画线、填充矩形、显示字符等函数;按键扫描模块,负责定时检测矩阵键盘或独立按键的状态,并去抖后生成清晰的事件(如按下、释放);定时器模块,我用一个基本定时器(如TIM2)产生固定的时间基准,比如每50ms中断一次,作为整个游戏的“心跳”。
- 游戏逻辑层:这是核心大脑。它定义游戏的核心数据结构(如地图、当前方块、下一个方块),实现方块的旋转、移动、碰撞检测、消行判断、分数计算等所有规则。这一层应该尽可能与硬件无关,理论上可以移植到其他平台。
- 显示与交互层:作为硬件驱动层和游戏逻辑层的桥梁。它调用驱动层的函数,将游戏逻辑层中的抽象数据(比如一个值为1的格子代表有方块)转化为屏幕上具体的像素和颜色。同时,它把驱动层上报的原始按键事件,映射为游戏逻辑层能理解的命令(如左移、右移、旋转、加速下落)。
为什么要这样分层?想象一下,如果你把画屏幕的代码和判断方块能不能旋转的代码混在一起,将来想换一块不同驱动的屏幕,或者把按键从矩阵键盘改成摇杆,你就会发现需要在一大团乱麻里找代码,改起来心惊胆战。分层之后,你只需要替换或修改驱动层,游戏逻辑几乎不用动。
2.2 核心数据结构设计
数据结构是程序的骨架,设计得好,逻辑就清晰,效率也高。
- 游戏地图:我用一个二维数组
uint8_t map[20][10]来表示。为什么是20行10列?这是经典俄罗斯方块的规格。数组元素为0表示该格子为空,为1-7的不同数字可以表示不同颜色/类型的方块残留部分。地图的顶部以上(数组索引为负)在逻辑上用于生成新方块。 - 当前活动方块:这是一个结构体,至少包含以下信息:
所有7种经典方块都可以用一个4x4的矩阵来表示其旋转状态。预定义好所有方块所有旋转形态的模板数组,是通用的做法。typedef struct { int8_t shape[4][4]; // 4x4的矩阵,描述方块的形状,1表示有,0表示无 int16_t x, y; // 方块左上角在地图坐标系中的位置 uint8_t type; // 方块类型(I, J, L, O, S, T, Z) uint8_t color; // 方块颜色,对应LCD的颜色值 } Tetromino; - 下一个方块:同样用一个
Tetromino结构体表示,在游戏初始化时随机生成,并在侧边或顶部预览区域显示。
2.3 游戏主循环与时间管理
这是嵌入式游戏编程的精华所在。你不能用while(1)里加delay这种阻塞方式,因为你需要同时响应按键和让方块自动下落。
我采用的是一个基于定时器中断的非阻塞主循环。
- 系统初始化后,进入一个
while(1)主循环。 - 主循环里只做三件事:
- 检查按键事件:调用按键扫描函数,如果检测到有有效按键动作,立即处理(比如立刻左移)。这保证了操作的即时性。
- 检查游戏时钟:设置一个全局变量
game_tick,在定时器中断服务函数里定期(如每50ms)加1。在主循环里检查,如果game_tick达到了下落一格所需的时间阈值(比如每10个tick下落一次,即500ms),就执行一次“方块下落一步”的逻辑,并重置计数器。这保证了游戏进程的匀速进行。 - 根据需要刷新局部显示:不是每一帧都重画整个屏幕。只有在方块移动、旋转、消行后,才刷新受影响的那部分屏幕区域。这能极大提高效率,避免屏幕闪烁。
定时器中断就像节拍器,稳定地打着拍子;主循环就像乐手,在每个拍子间隙检查是否需要弹奏音符(响应按键)以及何时该进入下一小节(方块下落)。两者配合,实现了多任务的错觉。
3. 核心模块实现与难点解析
有了顶层设计,我们来深入几个关键模块的实现细节和容易踩坑的地方。
3.1 方块旋转算法的精妙之处
旋转听起来简单,就是把一个4x4矩阵旋转90度。但难点在于旋转中心和碰撞检测。
1. 旋转模板法:最可靠的方法。我为7种方块(I, J, L, O, S, T, Z)各自预定义了4个4x4的矩阵,分别对应0、90、180、270度四种旋转状态。当玩家按下旋转键时,当前方块的type和rotation_index索引加1(对4取模),然后直接从模板数组里取出新的形状矩阵替换旧的。O型方块(正方形)的四个模板其实是一样的,这是一个特例。
2. 旋转后的碰撞预测与墙踢:方块旋转后,新的形状可能会嵌进墙里或其他方块里。这时不能简单地禁止旋转,体验会很差。俄罗斯方块有一个“墙踢”规则:如果旋转后发生碰撞,系统会尝试将方块向左、右或上轻微移动一个单位(根据标准规则有固定的测试位置顺序),如果某个偏移位置下不碰撞,就允许旋转并同时施加这个偏移。实现这个功能,需要你在check_collision()函数中,不仅检查当前位置,还要能接受一个偏移量参数进行预测检查。
注意:自己推导旋转公式和墙踢测试向量很容易出错且不标准。强烈建议参考官方的“标准旋转系统”(SRS)数据。网上可以找到这7种方块所有旋转状态以及墙踢测试的偏移量表,直接做成常量数组用到代码里,这是最专业和兼容性的做法。
3.2 高效碰撞检测的实现
碰撞检测是调用最频繁的函数之一,效率至关重要。它主要检查两类碰撞:与墙(地图边界)的碰撞,与已固定方块的碰撞。
// 检测一个方块在给定位置(cx, cy)和给定形状(shape[4][4])是否会碰撞 bool check_collision(int16_t cx, int16_t cy, const int8_t shape[4][4]) { for (int8_t i = 0; i < 4; i++) { for (int8_t j = 0; j < 4; j++) { if (shape[i][j]) { // 只检查形状矩阵中为1的格子 int16_t map_x = cx + j; int16_t map_y = cy + i; // 1. 检查是否超出左右边界或底边界 if (map_x < 0 || map_x >= MAP_WIDTH || map_y >= MAP_HEIGHT) { return true; // 碰撞 } // 2. 检查是否与地图中已固定的方块重叠(注意:map_y>=0才检查) if (map_y >= 0 && game_map[map_y][map_x]) { return true; // 碰撞 } } } } return false; // 无碰撞 }这个函数在方块尝试移动、旋转前被调用。关键点在于map_y >= 0这个判断。地图数组的索引0对应的是屏幕最顶端一行。当新方块在顶部生成时,其cy可能是负值(比如-2),表示一部分在可视区域上方。这时,我们只检查它是否撞到左右墙,而不检查与地图数组的碰撞,因为数组索引为负的部分是合法的生成区。
3.3 消行与地图更新优化
当一行被填满时,需要消除该行,并将上面的所有行整体下移。一个直观但低效的做法是:发现满行后,从该行开始,将上一行的数据逐行复制到下一行。
更优化的做法是使用“双指针”或“行标记”法:
- 从地图底部向上扫描,用一个
write_row指针指向当前准备写入的行(从底部开始)。 - 用另一个
read_row指针从底部向上扫描。 - 如果
read_row指向的行是满的,就跳过它,read_row上移,write_row不动。 - 如果
read_row指向的行不是满的,就将这行数据复制到write_row指向的位置,然后write_row和read_row都上移。 - 扫描结束后,
write_row以上的所有行都应该被清空。 这样做只需要一次遍历和必要的数据移动,效率更高。同时,在消行过程中,要记录消除了几行,用于分数计算(消除行数越多,单次得分系数越高)。
3.4 显示优化:局部刷新与双缓冲思想
STM32F103的刷屏速度有限,全屏刷新(尤其是320*240分辨率)会有明显的闪烁和延迟。我们必须进行局部刷新。
1. 方块移动的刷新:当方块移动或旋转时,我们只需要擦除方块在旧位置留下的痕迹,然后在新位置绘制方块。这就需要保存方块上一次的位置和形状。在每次成功移动后,将旧位置对应的地图区域(根据旧形状计算出的4x4格子覆盖的所有地图格子)重绘为背景色或地图状态,再绘制新位置的方块。
2. 消行时的刷新:消行后,从消除行到顶部,所有方块的位置都变了。最直接的方法是重绘整个游戏区域。但我们可以优化:计算出消行后,哪些行区域发生了变化(通常是从消除行到顶部的一个矩形区域),只刷新这个区域。虽然计算稍复杂,但在观感上提升明显。
3. 双缓冲的模拟:在资源足够的MCU上,可以开辟两块屏幕大小的显存,在一块“后台”显存上完成所有绘图操作,然后一次性DMA传输到LCD,这能完全杜绝闪烁。但STM32F103指南者的RAM可能不够(3202402字节=150KB > 64KB)。我们可以采用“区域双缓冲”:只为频繁更新的游戏区域(比如10列20行,每个格子88像素)开辟一块小缓存,更新在这个缓存里进行,然后一次性刷到屏幕对应位置。这需要更精细的内存管理。
4. 详细实现步骤与代码剖析
让我们一步步搭建起这个游戏。这里我会给出关键代码片段和配置思路。
4.1 硬件初始化与驱动准备
首先,确保你的开发环境(Keil、IAR或STM32CubeIDE)和硬件连接正确。LCD和按键的引脚定义需要根据你的“指南者”底板原理图来修改。
// 示例:基于STM32Cube HAL库的初始化框架 int main(void) { HAL_Init(); SystemClock_Config(); // 配置系统时钟,推荐72MHz // 初始化外设 MX_GPIO_Init(); MX_TIM2_Init(); // 用于游戏时钟的定时器 MX_SPI1_Init(); // 假设LCD使用SPI接口 // ... 其他必要外设 // 初始化LCD LCD_Init(); LCD_Clear(WHITE); // 清屏为白色背景 LCD_ShowString(10, 10, “STM32 Tetris”, BLACK, WHITE, 16, 0); // 初始化按键(假设使用扫描方式) Key_Init(); // 初始化游戏 Game_Init(); // 启动游戏时钟定时器 HAL_TIM_Base_Start_IT(&htim2); // 开启定时器中断 // 游戏主循环 while (1) { Game_ProcessInput(); // 处理按键 Game_Update(); // 更新游戏逻辑 Game_Render(); // 渲染画面(局部刷新) // 可以加入一个短延时,避免主循环空跑耗电,但非必须 // HAL_Delay(1); } }4.2 游戏逻辑核心代码实现
我们聚焦于游戏逻辑层最核心的几个函数。
游戏初始化Game_Init():
void Game_Init(void) { // 清空游戏地图 memset(game_map, 0, sizeof(game_map)); // 初始化随机数种子(可以用ADC读取悬空引脚噪声) srand(HAL_GetTick()); // 生成当前方块和下一个方块 current_block = Create_Random_Block(); next_block = Create_Random_Block(); // 重置游戏状态变量 game_score = 0; game_level = 1; drop_speed = INITIAL_SPEED; game_tick_counter = 0; game_over = false; // 绘制初始界面:边框、下一个方块预览区、分数显示区 Draw_Game_Border(); Draw_Next_Block(&next_block); Draw_Score(); }方块移动处理Game_ProcessInput():
void Game_ProcessInput(void) { uint8_t key = Key_GetValue(); // 获取按键值,0表示无按键 if (key == 0 || game_over) return; Tetromino test_block = current_block; bool moved = false; switch(key) { case KEY_LEFT: test_block.x--; if (!check_collision(test_block.x, test_block.y, test_block.shape)) { current_block.x--; moved = true; } break; case KEY_RIGHT: test_block.x++; if (!check_collision(test_block.x, test_block.y, test_block.shape)) { current_block.x++; moved = true; } break; case KEY_ROTATE: // 获取下一个旋转状态的形状 int8_t next_shape[4][4]; get_next_rotation(¤t_block, next_shape); // 尝试墙踢 if (try_wallkick(¤t_block, next_shape)) { moved = true; } break; case KEY_DOWN: // 加速下落 drop_speed_temp = FAST_DROP_SPEED; break; } if (moved) { // 记录旧位置用于擦除,然后更新显示 Block_Erase_Previous(); Block_Draw_Current(); } }游戏状态更新Game_Update():
void Game_Update(void) { if (game_over) return; // 检查是否该自动下落了 game_tick_counter++; if (game_tick_counter >= drop_speed) { game_tick_counter = 0; // 尝试下落一格 if (!check_collision(current_block.x, current_block.y + 1, current_block.shape)) { current_block.y++; Block_Erase_Previous(); Block_Draw_Current(); } else { // 无法下落,固定到地图 Lock_Block_To_Map(¤t_block); // 检查并消除满行 int lines_cleared = Check_and_Clear_Lines(); if (lines_cleared > 0) { Update_Score(lines_cleared); Draw_Score(); // 可能需要根据分数提升等级和速度 drop_speed = Calculate_Speed(game_level); } // 生成新的当前方块(即原来的下一个方块) current_block = next_block; current_block.x = SPAWN_X; // 重置到出生点 current_block.y = SPAWN_Y; // 生成新的下一个方块 next_block = Create_Random_Block(); Draw_Next_Block(&next_block); // 检查游戏是否结束(新方块出生即碰撞) if (check_collision(current_block.x, current_block.y, current_block.shape)) { game_over = true; Draw_GameOver(); } } } }4.3 显示渲染模块示例
以绘制一个方块格子和绘制下一个方块预览区为例:
// 绘制一个地图格子 (x, y 为地图数组坐标, color为颜色) void Draw_Cell(uint8_t x, uint8_t y, uint16_t color) { uint16_t screen_x = GAME_AREA_OFFSET_X + x * CELL_SIZE; uint16_t screen_y = GAME_AREA_OFFSET_Y + y * CELL_SIZE; // 留出1像素的间隙,使方块之间有空隙,更美观 LCD_Fill(screen_x+1, screen_y+1, screen_x+CELL_SIZE-1, screen_y+CELL_SIZE-1, color); // 可以给方块加一个浅色的边框,增加立体感 LCD_DrawRectangle(screen_x, screen_y, screen_x+CELL_SIZE, screen_y+CELL_SIZE, color_light); } // 绘制“下一个方块”预览区 void Draw_Next_Block(Tetromino *block) { // 1. 清空预览区域 LCD_Fill(NEXT_BLOCK_AREA_X, NEXT_BLOCK_AREA_Y, NEXT_BLOCK_AREA_X + 4*CELL_SIZE, NEXT_BLOCK_AREA_Y + 4*CELL_SIZE, BACKGROUND_COLOR); // 2. 绘制预览方块 for (int i = 0; i < 4; i++) { for (int j = 0; j < 4; j++) { if (block->shape[i][j]) { uint16_t screen_x = NEXT_BLOCK_AREA_X + j * CELL_SIZE; uint16_t screen_y = NEXT_BLOCK_AREA_Y + i * CELL_SIZE; LCD_Fill(screen_x+1, screen_y+1, screen_x+CELL_SIZE-1, screen_y+CELL_SIZE-1, block->color); } } } }5. 调试技巧、常见问题与优化建议
在实际焊接代码和调试的过程中,你肯定会遇到各种问题。这里分享一些我踩过的坑和解决思路。
5.1 典型问题排查清单
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 屏幕无显示或花屏 | 1. SPI/I2C/FSMC时序配置错误。 2. 初始化序列(寄存器配置)不正确或遗漏。 3. 背光未开启。 4. 电源或复位引脚问题。 | 1. 用逻辑分析仪或示波器抓取LCD初始化时的通信波形,与ILI9341数据手册对比。 2. 逐行核对LCD驱动代码的初始化函数,确保发送了正确的命令和数据序列。网上找的驱动代码可能因屏的批次不同而略有差异。 3. 检查背光控制引脚(BL)是否被正确拉高。 4. 测量LCD模块的VCC和GND电压是否稳定。 |
| 按键无反应或连击 | 1. 按键扫描频率不当(太快或太慢)。 2. 消抖算法失效。 3. 引脚配置错误(如上拉电阻未启用)。 4. 中断与主循环冲突。 | 1. 将扫描间隔调整到10-20ms左右。在按键中断或定时器中断中设置标志,在主循环中处理。 2. 实现稳定的消抖:检测到按下后,延时10ms再读一次,如果仍是按下状态则确认为有效按下。释放同理。 3. 检查GPIO初始化,按键引脚应配置为输入上拉模式。 4. 避免在中断服务函数中进行耗时操作或调用可能重入的函数。 |
| 方块移动/旋转卡顿 | 1. 碰撞检测函数效率低下,或逻辑错误导致死循环。 2. 屏幕刷新区域过大或全屏刷新。 3. 主循环被其他阻塞任务(如长延时)拖慢。 4. 定时器中断频率过高,处理负担重。 | 1. 优化check_collision函数,使用循环展开或减少不必要的判断。用调试器测量函数执行时间。2. 确保只刷新方块移动前后变化的区域,而不是整个游戏区。 3. 移除主循环中不必要的 HAL_Delay()。4. 将游戏时钟定时器中断频率降低,比如从1ms改为50ms,在中断里只设标志,逻辑在主循环处理。 |
| 游戏随机性差 | 随机数种子固定,导致每次游戏方块序列都一样。 | 使用一个不固定的值作为随机种子,如HAL_GetTick()(系统滴答时钟,上电后一直在变),或者读取一个未连接的ADC通道的噪声值。 |
| 消行后显示错乱 | 1. 消行后地图数据更新逻辑错误。 2. 屏幕刷新逻辑未覆盖所有变化区域。 3. 内存越界,破坏了其他数据。 | 1. 单步调试Check_and_Clear_Lines函数,观察地图数组每一步的变化是否正确。2. 在消行后,强制重绘整个游戏区域,看是否正常。如果正常,说明是局部刷新逻辑问题。 3. 检查所有数组访问的索引是否都在有效范围内(如 map[20][10]的索引是0-19和0-9)。 |
5.2 性能与体验优化建议
当基础功能实现后,可以考虑以下优化来提升游戏品质:
- 加入动画效果:方块固定时的闪烁效果、消行时的逐行消失动画。这可以通过在定时器中断中快速切换颜色状态来实现,增加视觉反馈。
- 实现“幽灵方块”:在游戏区域底部,用一个半透明的轮廓显示方块如果立即下落会到达的位置。这能极大提升玩家的策略性。实现方法就是从当前方块位置向下模拟下落,直到碰撞,然后在那个位置画一个半透明(或虚线框)的方块。
- 添加音效:利用STM32的PWM或DAC,结合一个简单的蜂鸣器,可以发出不同频率的响声,用于移动、旋转、消行、游戏结束等事件。哪怕只是“嘀嘀”声,体验也提升一个档次。
- 设计更友好的UI:用不同颜色区分方块,绘制平滑的游戏边框,添加分数、等级、下一个方块的文字标签,让界面更完整。
- 使用状态机管理游戏流程:将游戏划分为“开始画面”、“游戏中”、“暂停”、“游戏结束”等状态。用状态机来管理,可以使代码逻辑更清晰,更容易扩展新功能(比如暂停功能)。
5.3 进阶挑战:移植与扩展
如果你已经完美实现了基础版本,可以尝试以下更有挑战性的任务:
- 移植到其他屏幕:尝试把你的驱动层抽象得更好,然后移植到OLED(I2C/SPI)或者另一种TFT屏上。这能检验你代码的模块化程度。
- 使用RTOS:尝试在FreeRTOS上重构这个游戏。创建一个按键扫描任务、一个游戏逻辑更新任务、一个显示渲染任务。你会对任务间通信(队列、信号量)有更深的理解。
- 添加存储功能:利用STM32的内部Flash或外接EEPROM,保存最高分记录。
- 制作手持设备:设计一个3D打印外壳,将指南者核心板、电池、小屏幕和按键组装在一起,做成一个真正的掌上俄罗斯方块游戏机。
这个项目从点亮屏幕到流畅游戏,每一步都需要你仔细思考硬件特性和软件逻辑的配合。调试过程可能充满挫折,但当屏幕上第一个方块受控落下,当第一行被成功消除时,那种成就感是无与伦比的。它不仅仅是一个游戏,更是你嵌入式开发能力的一个坚实里程碑。