基于STM32F103的俄罗斯方块游戏开发:从硬件驱动到游戏逻辑实现

发布时间:2026/7/31 5:40:27
基于STM32F103的俄罗斯方块游戏开发:从硬件驱动到游戏逻辑实现 1. 项目概述当经典游戏遇上嵌入式开发俄罗斯方块这个诞生于上世纪80年代的经典游戏几乎刻在了每个玩家的记忆里。但你是否想过抛开电脑和手机在一块小小的单片机开发板上亲手实现这个游戏会是什么体验今天我想分享的就是基于STM32F103指南者开发板从零开始打造一个俄罗斯方块小游戏的完整过程。这不仅仅是一个简单的编程练习它融合了嵌入式系统的实时控制、图形显示、人机交互和状态机设计等多个核心知识点是检验和提升嵌入式开发能力的绝佳项目。对于刚接触STM32的开发者来说这个项目能让你直观地理解如何驱动一块LCD屏幕、如何通过按键进行实时响应、如何管理一个复杂的游戏逻辑状态。而对于有经验的工程师它则是一个很好的框架可以让你思考如何优化代码结构、提高刷新效率、甚至加入更复杂的音效和动画。我选择STM32F103指南者是因为它作为一款经典的Cortex-M3内核MCU资源适中社区资料丰富配套的2.8寸TFT-LCD彩屏和按键也正好满足我们的需求。整个项目下来你会对嵌入式GUI哪怕是最简单的、事件驱动编程有更深的理解。接下来我就把从硬件连接到软件架构再到每一个方块旋转、消行逻辑的实现细节以及我踩过的那些坑毫无保留地分享给你。2. 硬件平台与开发环境搭建2.1 核心硬件选型与连接我们项目的硬件核心是STM32F103指南者开发板。这块板子上的MCU是STM32F103VET6拥有512KB的Flash和64KB的RAM主频最高72MHz性能对于我们的俄罗斯方块游戏绰绰有余。最关键的外设是板载的2.8寸TFT-LCD彩屏其驱动芯片通常是ILI9341或类似的通过FSMC灵活的静态存储器控制器接口与MCU连接这能提供极高的刷屏速度。另外板载的五个独立按键上、下、左、右、中将作为我们的游戏控制输入。在开始编程前确保你的硬件连接正确。通常开发板的LCD已经通过排线连接好。你需要检查的是在工程中是否正确配置了FSMC的地址线和数据线以匹配LCD的8080并行接口时序。以ILI9341为例我们需要配置FSMC Bank1的某个区域例如NE4数据宽度为16位并设置好读写时序寄存器。这些配置一般在ili9341.c的初始化函数里完成。一个常见的坑是时序配置不当导致屏幕花屏或者根本点不亮。我的经验是先使用卖家提供的例程驱动屏幕显示一些测试图形确保硬件底层是通的然后再将其移植到自己的工程框架中。2.2 软件开发环境配置软件环境我推荐使用Keil MDKARMCC编译器或者STM32CubeIDEGCC编译器。两者各有优劣Keil的调试器比较强大CubeIDE则免费且与STM32CubeMX工具链集成得更好。我个人更倾向于使用STM32CubeMX进行图形化引脚和时钟初始化然后用Keil或CubeIDE进行代码编写和调试。第一步用CubeMX新建一个针对STM32F103VET6的工程。关键配置点如下时钟树Clock Configuration将HCLK系统时钟设置为72MHz这是F103的极限性能能保证游戏流畅运行。FSMC配置使能FSMC选择LCD Interface模式映射到正确的Bank和引脚。数据线D0-D15、地址线通常用A18作为RS命令/数据选择线、读写使能线NOE, NWE、片选线NE4都需要正确配置。GPIO配置将五个按键对应的引脚例如PA0, PA1, PA2, PA3, PA4配置为输入模式并开启上拉。这样按键未按下时引脚为高电平按下时为低电平。Systick定时器配置SysTick定时器中断用于产生游戏的核心心跳——下落计时。比如我们可以设置每500ms产生一次中断来控制方块自动下落一格。生成代码后你会得到一个包含HAL库初始化的完整工程。接下来我们需要将LCD的底层驱动代码如ili9341.c/h,lcd.c/h和字体文件加入工程。确保在main.c中正确调用LCD_Init()并在屏幕上打印出“Hello, Tetris!”之类的测试信息这标志着你的基础环境搭建成功。3. 游戏核心逻辑设计与实现3.1 游戏数据结构定义俄罗斯方块的核心是“方块”和“场地”。我们需要用精炼的数据结构来表征它们。在C语言中我通常这样定义// 定义7种经典俄罗斯方块形态每种形态有4个旋转状态 // 使用4x4的位矩阵表示1代表有方块0代表空 const uint8_t TETROMINOS[7][4][16] { // I型 { {0,0,0,0, 1,1,1,1, 0,0,0,0, 0,0,0,0}, // ... 其他三种旋转状态 }, // J, L, O, S, T, Z 型... }; // 当前活动方块的结构体 typedef struct { int8_t type; // 方块类型 (0-6) int8_t rotation; // 旋转状态 (0-3) int16_t x; // 方块左上角在场地中的x坐标 (以格子为单位) int16_t y; // 方块左上角在场地中的y坐标 } ActiveBlock; // 游戏场地 (10列 x 20行) #define FIELD_WIDTH 10 #define FIELD_HEIGHT 20 uint8_t gameField[FIELD_HEIGHT][FIELD_WIDTH]; // 0为空非0为已固定的方块可存储颜色信息gameField数组是整个游戏的“地图”记录哪些位置已经被固定的方块占据。ActiveBlock则代表玩家正在操控的下落方块。这种分离的设计非常清晰活动方块独立运算判断其移动、旋转是否合法固定场地则作为碰撞检测的基准。3.2 方块运动与碰撞检测算法方块的移动左、右、下和旋转是游戏交互的基础。所有操作都必须先进行碰撞检测。移动检测当玩家按下左键我们尝试将ActiveBlock的x坐标减1。然后遍历这个方块4x4矩阵中所有为1的格子计算其在场地中的实际坐标(field_x, field_y)。检查gameField[field_y][field_x]是否为0空并且field_x是否在0到FIELD_WIDTH-1的范围内。只有所有格子都通过检测移动才被允许。旋转检测旋转稍微复杂。我们有一个TETROMINOS[type][rotation]数组。当按下旋转键我们计算目标旋转状态(current_rotation 1) % 4并获取新的4x4矩阵。同样遍历新矩阵中所有为1的格子计算其场地坐标并进行碰撞和边界检测。这里有一个著名的“墙踢”机制如果旋转后发生碰撞可以尝试将方块向左、右或上微调一格再检测一次。我在实现时简化了只做了基本的碰撞检测这已经能保证游戏可玩。如果你想更专业可以参考官方的“超级旋转系统”规则。下落与固定系统定时如每秒2次或玩家按“下”键会触发下落。下落检测同移动检测。如果下落一格合法则更新y坐标如果不合法到底或碰到其他方块则触发“固定”过程将当前活动方块的所有格子写入gameField数组并生成一个新的随机方块作为新的活动方块。同时检查是否有行被填满触发消行逻辑。注意碰撞检测的坐标计算是极易出错的地方。务必厘清4x4矩阵原点、活动方块(x,y)坐标代表矩阵左上角在场地中的位置和场地数组索引[行][列]之间的关系。我建议在调试时将场地和活动方块的矩阵用printf打印到串口能直观地发现问题。4. 图形界面与用户交互实现4.1 LCD图形绘制与优化有了游戏数据下一步就是将其可视化。我们需要两个基本的绘图函数DrawBlock画一个实心小方块和DrawField绘制整个场地和活动方块。绘制策略为了提高效率我们采用“脏矩形”更新策略而不是每一帧都重绘整个屏幕。即只绘制发生变化的部分。例如固定场地只在方块固定、消行后重绘受影响的行。活动方块在方块移动或旋转前用背景色在旧位置“擦除”它然后在新位置用方块色绘制它。这样只需要更新方块覆盖的少量区域。// 绘制一个方块单元假设每个格子16x16像素 void DrawBlock(int16_t x, int16_t y, uint16_t color) { LCD_Fill(x, y, x15, y15, color); // 假设LCD_Fill是填充矩形函数 } // 绘制整个游戏区域用于初始化或全屏刷新 void DrawGameField(void) { // 绘制背景和网格线... for(int row 0; row FIELD_HEIGHT; row) { for(int col 0; col FIELD_WIDTH; col) { if(gameField[row][col]) { DrawBlock(col*16, row*16, GetColor(gameField[row][col])); } else { DrawBlock(col*16, row*16, BACKGROUND_COLOR); } } } // 绘制活动方块 DrawActiveBlock(); }颜色与视觉为每种方块类型分配不同的颜色如I型青色O型黄色能让游戏更美观。可以在DrawBlock函数中根据传入的color参数填充。同时在场地边界绘制一个边框能提升视觉效果。4.2 按键扫描与去抖处理用户通过五个按键控制游戏左、右、下加速下落、旋转、开始/暂停。我们需要一个高效的按键扫描程序。我推荐使用状态机进行按键检测它能很好地处理按下、长按、释放等状态并有效去抖。typedef enum {KEY_IDLE, KEY_DEBOUNCE, KEY_PRESSED, KEY_HOLD} KeyState; void KeyScan(void) { static KeyState keyState[5] {KEY_IDLE}; static uint32_t holdTimer[5] {0}; uint8_t currentKeyValue ReadKeyGPIO(); // 读取5个按键的当前电平 for(int i0; i5; i) { switch(keyState[i]) { case KEY_IDLE: if((currentKeyValue (1i)) 0) { // 按键按下低电平 keyState[i] KEY_DEBOUNCE; } break; case KEY_DEBOUNCE: // 延时10ms后再次检测确认按下 if(/* 10ms timer expired */) { if((currentKeyValue (1i)) 0) { keyState[i] KEY_PRESSED; keyEvent i; // 触发按键按下事件 } else { keyState[i] KEY_IDLE; } } break; case KEY_PRESSED: keyState[i] KEY_HOLD; holdTimer[i] 0; break; case KEY_HOLD: holdTimer[i]; // 例如每100ms触发一次长按事件用于连续移动 if(holdTimer[i] 10) { keyEvent i; // 触发长按事件 holdTimer[i] 0; } if((currentKeyValue (1i)) ! 0) { // 按键释放 keyState[i] KEY_IDLE; } break; } } }在主循环中不断调用KeyScan()并根据keyEvent变量执行相应的游戏逻辑如移动方块。将按键扫描放在SysTick中断或一个定时器中断中能确保响应的实时性。5. 游戏状态机与高级功能实现5.1 主循环与游戏状态管理一个清晰的游戏状态机能让代码结构更健壮。我们至少需要以下几个状态MENU菜单、PLAYING游戏中、PAUSED暂停、GAME_OVER结束。typedef enum {GAME_MENU, GAME_PLAYING, GAME_PAUSED, GAME_OVER} GameState; GameState currentState GAME_MENU; void GameMainLoop(void) { while(1) { KeyScan(); // 扫描按键 switch(currentState) { case GAME_MENU: // 显示菜单按键选择开始游戏 if(StartKeyPressed()) { GameInit(); // 初始化场地、分数、生成第一个方块 currentState GAME_PLAYING; } break; case GAME_PLAYING: ProcessKeyEvents(); // 处理移动、旋转、加速下落 if(IsTimeToFall()) { // 定时下落 if(!MoveBlockDown()) { // 尝试下落一格失败则固定 FixBlockToField(); ClearFullLines(); if(!SpawnNewBlock()) { // 生成新方块失败则游戏结束 currentState GAME_OVER; } } } DrawGame(); // 更新图形 if(PauseKeyPressed()) { currentState GAME_PAUSED; } break; case GAME_PAUSED: // 显示暂停界面 if(PauseKeyPressedAgain()) { currentState GAME_PLAYING; } break; case GAME_OVER: // 显示分数和“Game Over” if(ResetKeyPressed()) { currentState GAME_MENU; } break; } HAL_Delay(10); // 主循环延时避免CPU跑飞 } }这个主循环结构清晰将输入、逻辑更新、渲染分离易于维护和扩展。5.2 消行计分与难度递增消行是游戏的正反馈核心。当方块固定后我们需要从下往上遍历gameField的每一行检查是否全部非空。uint8_t ClearFullLines(void) { uint8_t linesCleared 0; for(int row FIELD_HEIGHT-1; row 0; row--) { uint8_t isFull 1; for(int col 0; col FIELD_WIDTH; col) { if(gameField[row][col] 0) { isFull 0; break; } } if(isFull) { // 将该行以上所有行向下移动一行 for(int r row; r 0; r--) { for(int col 0; col FIELD_WIDTH; col) { gameField[r][col] gameField[r-1][col]; } } // 最顶行清空 for(int col 0; col FIELD_WIDTH; col) { gameField[0][col] 0; } row; // 因为当前行已被新的行覆盖需要再检查一次同一位置 linesCleared; } } if(linesCleared 0) { UpdateScore(linesCleared); // 更新分数消1行100分2行300分3行500分4行800分是经典规则 // 根据总分或已消行数提高下落速度减少SysTick中断间隔 AdjustFallSpeed(); // 重绘被消行影响的区域 RedrawFieldArea(); } return linesCleared; }难度递增可以通过AdjustFallSpeed()函数实现。例如每消10行就将方块自动下落的时间间隔减少50ms直到一个最低下限如100ms以增加游戏挑战性。6. 性能优化与调试技巧实录6.1 内存与CPU使用优化在资源受限的STM32F103上优化至关重要。减少全局变量尽量使用局部变量和静态局部变量。将大的常量数组如方块形状数据用const修饰放在Flash中节省RAM。优化绘制函数LCD_Fill是瓶颈。确保它底层使用的是DMA传输或优化后的内存拷贝。避免在绘制单个方块时频繁设置绘制窗口可以批量绘制。避免浮点数运算游戏逻辑全部使用整数运算。坐标、分数都用int16_t或uint32_t。合理使用中断SysTick用于定时下落定时器中断用于按键扫描。主循环只负责状态调度和渲染确保系统响应流畅。6.2 常见问题与调试心得在开发过程中我遇到了不少典型问题这里分享排查思路问题1方块旋转时图形撕裂或位置错误。排查首先检查旋转后的4x4矩阵数据是否正确。其次检查“擦除旧方块”和“绘制新方块”的坐标计算是否正确确保是先擦后画。最后用逻辑分析仪或调试器查看按键事件触发时ActiveBlock的type,rotation,x,y变量变化是否符合预期。心得编写一个PrintFieldToUART()函数将场地和活动方块矩阵以字符形式打印到串口这是最直观的调试手段。问题2游戏运行一段时间后卡顿。排查检查是否有内存泄漏虽然C语言很少见但数组越界可能破坏堆栈。使用Keil的Event Recorder或SystemView工具查看任务执行时间和中断频率判断是否是渲染耗时太长或中断过于频繁。心得在DrawGame()函数前后加GPIO翻转语句用示波器测量其执行时间。优化最耗时的部分比如减少全屏刷新。问题3按键响应不灵敏或有连击。排查首先确认硬件上拉电阻是否有效按键引脚是否有接触不良。然后检查软件去抖时间是否合适10-20ms。最后检查按键状态机逻辑确保KEY_PRESSED状态只触发一次动作而KEY_HOLD状态有适当的延时后才触发连续动作。心得将去抖检测放在定时器中断中而不是主循环里用HAL_Delay这样更准确且不影响主循环其他任务。问题4屏幕刷新有闪烁感。排查这通常是“擦除-绘制”过程太慢被肉眼捕捉到中间态。确保两次绘制操作间隔尽可能短。可以考虑使用双缓冲机制但F103的RAM可能不足以开辟整个屏幕的缓冲区3202402150KB 64KB。退而求其次只对游戏区域进行局部双缓冲或使用LTDC图层混合如果LCD支持并启用。心得在性能不够时可以适当降低游戏区域的刷新率或者优化底层LCD_WriteData函数使用半字或字传输代替字节传输。这个项目做下来最深的体会是嵌入式开发是软硬结合的极致体现。每一个流畅动画的背后都是对硬件资源的精打细算和对软件时序的严格把控。从最初的点亮屏幕到最终一个反应灵敏、画面稳定的游戏每一步问题的解决都是能力的提升。当你看到自己编写的代码让一个个方块在小小的屏幕上旋转、落下、消行那种成就感是无可替代的。它不仅仅是一个游戏更是一个完整的嵌入式系统应用实例。你可以在此基础上继续扩展比如加入蜂鸣器播放简单的游戏音效用Flash存储最高分甚至通过蓝牙模块实现双人对战探索的空间依然很大。