ARM7嵌入式俄罗斯方块实现:数据结构、碰撞检测与图形渲染实战
2026/6/5 14:14:09 网站建设 项目流程

1. 项目概述与核心思路

手头有一块闲置的320x240的彩屏液晶,确认功能完好后,总想用它做点有意思的东西。对于嵌入式开发者来说,把一个经典游戏从零开始移植到自己的硬件平台上,是检验综合能力、加深对底层理解的最佳实践之一。我选择了俄罗斯方块,这个游戏规则简单,但背后涉及的逻辑建模、实时控制、人机交互和图形渲染,恰恰是嵌入式系统开发的缩影。这次我基于ARM7架构的S3C44B0微控制器,完成了整个游戏的实现。整个过程不仅是对C语言和数据结构功底的考验,更是对如何将抽象的游戏逻辑映射到有限的硬件资源(内存、算力、显示)上的一次深度探索。如果你也有一块MCU和屏幕,无论是STM32、GD32还是其他ARM Cortex-M系列,甚至51单片机,跟着这个思路走一遍,绝对能收获远超一个游戏本身的嵌入式开发经验。

2. 游戏核心建模:从抽象到数据结构

实现任何游戏,第一步也是最关键的一步,就是建立准确的数学模型。俄罗斯方块的核心模型有两个:下落方块静态背景。用对了数据结构,后续的碰撞检测、旋转、消行都会变得清晰简单。

2.1 方块的数据结构设计

标准的俄罗斯方块有7种基本形状。如何用程序表示它们?最直观的方法就是使用二维数组。考虑到方块旋转后所占空间,我们用一个5x5的布尔矩阵(0表示空,1表示有方块)来定义每个形状。这样,每个形状都有足够的“活动空间”进行旋转操作。

例如,那个长长的“I”形方块,在5x5矩阵中可以表示为:

int I_Shape[5][5] = { {0, 0, 0, 0, 0}, {0, 0, 0, 0, 0}, {1, 1, 1, 1, 0}, // 核心部分 {0, 0, 0, 0, 0}, {0, 0, 0, 0, 0} };

你可能会问,为什么用5x5而不是4x4?这是为了统一和简化旋转逻辑。有些方块(如“T”形、“S”形)在4x4网格中旋转时,其“重心”会偏移,导致旋转后的位置难以对齐网格,给碰撞检测带来不必要的麻烦。5x5网格为所有方块提供了一个固定的、居中的旋转轴心,使得旋转操作可以统一为围绕中心点(或固定偏移点)的矩阵变换,逻辑上更规整。

为了管理所有方块,我定义了一个三维数组box[7][5][5],一次性存储全部7种基本形状的初始状态。这是游戏的“形状库”。

2.2 方块的旋转算法

方块旋转,本质上就是对一个5x5矩阵进行90度的旋转变换。我编写了一个通用的旋转函数rotateBox。它的输入是原始矩阵box1,输出是旋转后的矩阵box2

void rotateBox(int box1[5][5], int box2[5][5]) { int x, y; for(x = 0; x < 5; x++) { for(y = 0; y < 5; y++) { // 核心:将原矩阵的行列进行转换并反向 box2[y][x] = box1[x][4 - y]; } } }

这里有个关键细节box1[x][4 - y]中的4 - y实现了矩阵的垂直翻转。因为屏幕坐标系(Y轴向下为正)和数学矩阵坐标系(Y轴向上为正)的差异,直接转置得到的旋转方向可能和预期相反。这个4 - y就是用来修正这个差异的,确保方块在屏幕上的旋转方向符合玩家习惯(顺时针旋转)。在实际调试时,如果发现旋转方向反了,调整这个翻转逻辑即可。

2.3 游戏场地(背景)的建模

游戏区域通常被定义为可见的网格,比如12行20列。但是,为了简化边界碰撞检测,我采用了一种经典的“哨兵”技巧。我将场地数组定义得更大一些,例如map[16][22]。然后,将数组的最左两列(0,1列)、最右两列(20,21列)以及最下两行(14,15行)全部初始化为1(视为墙壁或不可逾越的边界)。中间12x20的区域初始化为0,代表可放置空间。

#define MAP_VISIBLE_ROWS 12 #define MAP_VISIBLE_COLS 20 #define MAP_PADDING 2 // 每边增加的“哨兵”宽度 #define MAP_TOTAL_ROWS (MAP_VISIBLE_ROWS + MAP_PADDING) // 16 #define MAP_TOTAL_COLS (MAP_VISIBLE_COLS + 2*MAP_PADDING) // 22 int map[MAP_TOTAL_ROWS][MAP_TOTAL_COLS]; void initMap(void) { int x, y; for(y = 0; y < MAP_TOTAL_ROWS; y++) { for(x = 0; x < MAP_TOTAL_COLS; x++) { // 如果是左右边界或底部边界,则设为1(墙) if(x < MAP_PADDING || x >= MAP_TOTAL_COLS - MAP_PADDING || y >= MAP_TOTAL_ROWS - MAP_PADDING) { map[y][x] = 1; } else { map[y][x] = 0; // 游戏可玩区域 } } } }

这样做的好处是巨大的:在判断方块是否碰到左、右、下边界时,我们无需检查坐标是否小于0或大于最大值,只需判断目标位置的地图数组值是否为1。这相当于把复杂的边界条件判断,简化成了统一的数据访问和逻辑与操作,代码更简洁,运行更高效。

3. 核心逻辑实现:碰撞、放置与消行

有了模型,游戏的核心循环就围绕着三个关键操作展开:碰撞检测方块固定消行判断

3.1 碰撞检测的精髓

这是游戏逻辑中最核心的函数。它的作用是判断一个方块(以其左上角为参考点(curX, curY))在当前位置下,是否与地图上已有的方块(或边界墙)发生重叠。

// 检测方块在(mapX, mapY)位置是否与地图冲突 // 返回1表示可以放置(无碰撞),返回0表示冲突 int checkCollision(int mapX, int mapY, int box[5][5]) { int i, j; for(i = 0; i < 5; i++) { // 遍历方块的5行 for(j = 0; j < 5; j++) { // 遍历方块的5列 // 只关心方块中为1的格子 if(box[i][j] == 1) { // 计算该格子在地图上的绝对坐标 int mapCellX = mapX + j; int mapCellY = mapY + i; // 如果地图上对应位置也为1,则发生碰撞 if(map[mapCellY][mapCellX] == 1) { return 0; // 冲突! } } } } return 1; // 安全,无冲突 }

实操心得:遍历方块数组时,我通常从0到4循环。但这里有一个隐藏的优化点:方块的5x5数组中,大部分是0。如果从中心向外检测,或者记录每个方块的有效格子的相对坐标列表,可以提前检测到碰撞,减少平均检测次数。不过对于ARM7这个级别的处理器,5x5的全遍历完全在能力范围内,代码的清晰度比这点微优化更重要。

这个函数被用于:

  1. 方块移动前:判断左、右、下移动是否合法。
  2. 方块旋转前:先计算出旋转后的形态,然后用此函数检测旋转后是否碰撞。如果碰撞,则取消本次旋转(许多游戏允许“踢墙”旋转,那是更高级的逻辑,此处为简化版)。
  3. 方块自然下落前:判断下一帧是否可以继续下落。

3.2 方块固定与地图更新

当碰撞检测函数发现方块无法继续下落时(即下方格子为1),就需要将当前方块“固化”到地图中。

void solidifyBoxToMap(int mapX, int mapY, int box[5][5]) { int i, j; for(i = 0; i < 5; i++) { for(j = 0; j < 5; j++) { if(box[i][j] == 1) { // 将方块的非零格子写入地图对应位置 // 注意:写入的值可以是1,也可以是一个颜色值,便于后续渲染 map[mapY + i][mapX + j] = 1; // 或 map[mapY + i][mapX + j] = currentColor; } } } }

注意:这里有一个极其重要的细节,我早期调试时在这里栽过跟头。mapXmapY是方块左上角在地图数组中的坐标。在写入时,一定要确保mapY + imapX + j没有越界。由于我们之前设置了“哨兵”边界,并且碰撞检测已经确保方块不会与边界外的“墙”重叠,所以写入操作是安全的。但如果你修改了模型,没有哨兵,就必须在此处加入边界检查,否则会引发内存访问错误,导致程序崩溃。

3.3 消行检测与地图压缩

方块固定后,需要检查是否有完整的行被填满,这是玩家的得分点。

int clearFullLines(void) { int linesCleared = 0; int y; // 注意:从下往上检查,这样删除一行后,上面的行下落逻辑简单 for(y = MAP_TOTAL_ROWS - 1 - MAP_PADDING; y >= MAP_PADDING; y--) { // 只遍历可视区域 int lineFull = 1; for(int x = MAP_PADDING; x < MAP_TOTAL_COLS - MAP_PADDING; x++) { if(map[y][x] == 0) { lineFull = 0; break; } } if(lineFull) { linesCleared++; // 将第y行以上的所有行整体下移一行 for(int moveY = y; moveY > MAP_PADDING; moveY--) { for(int x = MAP_PADDING; x < MAP_TOTAL_COLS - MAP_PADDING; x++) { map[moveY][x] = map[moveY - 1][x]; } } // 最顶行清空 for(int x = MAP_PADDING; x < MAP_TOTAL_COLS - MAP_PADDING; x++) { map[MAP_PADDING][x] = 0; } // 因为当前行已经被上面的行覆盖,需要再次检查这个新移下来的行 y++; } } return linesCleared; // 返回消除的行数,用于计算分数 }

避坑指南:消行逻辑的陷阱在于行下移的顺序。必须从被消除的行开始,从上往下逐行复制数据。如果从顶行开始往下复制,会导致数据被错误覆盖。上面的代码采用了一个技巧:当消除第y行后,用一个内层循环将y行以上的所有行下移。y++这行代码很关键,因为整体下移后,当前循环索引y指向的是从上面移下来的新行,需要再次检查它是否也是满的(比如连续消多行的情况)。

4. 主游戏循环与状态管理

一个清晰的游戏状态机是程序稳定的基础。我的主循环结构如下:

// 全局状态变量 int curBox[5][5]; // 当前下落中的方块 int nextBox[5][5]; // 下一个预备方块(用于预览) int curX, curY; // 当前方块在地图中的坐标 int gameOver = 0; int score = 0; int level = 1; void gameMainLoop(void) { initMap(); generateNewBox(nextBox); // 生成第一个预览方块 while(!gameOver) { // 1. 让预览方块变为当前方块 memcpy(curBox, nextBox, sizeof(curBox)); curX = MAP_PADDING + MAP_VISIBLE_COLS / 2 - 2; // 初始居中放置 curY = MAP_PADDING; generateNewBox(nextBox); // 为下一轮生成新的预览方块 // 2. 检查新方块是否可放置。如果不能,说明堆到顶了,游戏结束。 if(!checkCollision(curX, curY, curBox)) { gameOver = 1; break; } // 3. 当前方块的下落循环 int thisBoxActive = 1; while(thisBoxActive && !gameOver) { // 3.1 处理用户输入(非阻塞方式) processUserInput(); // 3.2 尝试让方块下落一格 if(checkCollision(curX, curY + 1, curBox)) { curY++; // 可以下落,更新坐标 } else { // 无法下落,固定到地图 solidifyBoxToMap(curX, curY, curBox); // 检查并消行 int lines = clearFullLines(); updateScoreAndLevel(lines); // 更新分数和等级(等级影响下落速度) thisBoxActive = 0; // 结束当前方块的生命周期 } // 3.3 渲染游戏画面 renderGameScene(); // 3.4 延时,控制下落速度。速度随等级提高而加快。 delay_ms(calculateDropSpeed(level)); } } // 游戏结束,显示分数等 showGameOverScreen(); }

关键点解析

  1. 输入处理processUserInput()必须是非阻塞的。通常通过定时器中断或主循环中快速扫描按键来实现。在ARM7上,我配置了一个定时器中断,每10ms扫描一次GPIO按键状态,并设置相应的动作标志(如左移、右移、旋转、加速下落),在主循环中响应这些标志。
  2. 速度控制calculateDropSpeed(level)函数根据当前等级返回一个延时毫秒数。等级越高,延时越短,方块下落越快。一个简单的公式是:return BASE_SPEED - (level-1) * SPEED_STEP;,并确保最小速度不为零。
  3. 渲染优化:在320x240这种分辨率不高的屏幕上,全屏刷新(特别是用软件画点)可能比较慢。我的优化策略是“差异刷新”。只刷新发生变化的部分:
    • 擦除上一帧方块的位置(将5x5区域背景重绘)。
    • 绘制当前帧方块在新位置。
    • 只有当方块固定、消行导致大面积地图变化时,才局部或全屏刷新地图区域。

5. 在ARM7 (S3C44B0) 上的具体实现要点

理论模型建立后,在真实的硬件上跑起来,会遇到一系列工程问题。

5.1 显示驱动与图形库

S3C44B0没有内置的LCD控制器,我使用的是并口(比如8080或6800时序)的TFT彩屏模块。你需要根据屏幕的数据手册,编写底层的引脚初始化、写命令、写数据函数。

// 伪代码示例:向LCD发送一个16位颜色数据 void LCD_WriteData16(uint16_t data) { SET_CS_LOW(); // 片选使能 SET_RS_HIGH(); // 选择数据寄存器 DATA_PORT = data; // 将16位数据放到数据总线上 SET_WR_LOW(); // 产生写脉冲 delay_ns(10); // 短暂延时,满足时序要求 SET_WR_HIGH(); SET_CS_HIGH(); }

基于这些底层函数,我封装了一个简单的图形库,包含画点、画矩形、填充矩形、显示字符和汉字(使用字库)的函数。对于俄罗斯方块,画点函数是性能关键。确保你的画点函数是优化过的,直接操作显存(如果屏幕有显存)或高效地通过总线发送数据。

5.2 按键输入与去抖

我使用了4个GPIO口连接独立按键,分别对应左、右、下、旋转。按键处理必须去抖。

// 在定时器中断服务程序(例如10ms一次)中处理 void Timer_IRQHandler(void) { static uint8_t key_history[4] = {0xFF, 0xFF, 0xFF, 0xFF}; // 假设按键按下为0 uint8_t key_raw = (~READ_KEY_PORT()) & 0x0F; // 读取4个按键,取反并掩码 for(int i=0; i<4; i++) { key_history[i] = (key_history[i] << 1) | ((key_raw >> i) & 0x01); // 当检测到连续3次采样都是按下状态(0x07),认为按键有效按下 if(key_history[i] == 0x07) { key_pressed_flag[i] = 1; // 设置按键按下标志 } // 当检测到释放(0xF8),清除标志 if(key_history[i] == 0xF8) { key_pressed_flag[i] = 0; } } // ... 清除定时器中断标志 }

在主循环中,检查key_pressed_flag即可执行相应动作。这种状态机去抖法比简单的延时去抖更可靠,不阻塞系统。

5.3 定时与游戏节奏

游戏需要两个定时基准:

  1. 方块自动下落定时:使用一个软件计数器。在主循环中,每次循环累加一个时间变量,当该变量超过当前等级对应的下落间隔时,触发一次“尝试下落”操作,然后重置计数器。
  2. 按键响应定时:为了避免长按按键时移动/旋转过快,需要设置一个“重复速率”。例如,当检测到按键持续按下超过300ms后,开始每100ms触发一次移动。这可以通过在按键处理逻辑中增加计时器来实现。

5.4 内存与性能考量

S3C44B0资源有限(可能只有几KB的片内RAM)。我们的主要数据结构:

  • map[16][22]: 352字节(假设为char型)。
  • curBox[5][5]nextBox[5][5]: 50字节。
  • 图形缓冲区:如果使用双缓冲区,3202402(16位色)= 150KB,这远远超出了片内RAM。因此必须采用直接写屏单缓冲区+局部刷新的策略。

性能瓶颈通常在图形绘制。避免在每次循环中重绘整个背景。只绘制变化的方块和消行区域。绘制方块时,可以预先计算好每个方块形态对应的位图(或颜色块),而不是动态计算每个点。

6. 调试技巧与常见问题排查

在嵌入式环境调试没有printf,通常依赖LED和屏幕。

  1. 问题:方块不显示或显示错位。

    • 排查:首先确认你的画点函数坐标系统是否正确。屏幕的(0,0)点通常是左上角,Y轴向下递增。检查方块坐标(curX, curY)转换为屏幕坐标(screenX, screenY)的公式。screenX = curX * BLOCK_SIZEscreenY = curY * BLOCK_SIZE。确保BLOCK_SIZE(每个游戏格子的像素大小)计算正确。
    • 工具:写一个测试函数,在屏幕固定位置画一个标记,确认基础绘图功能正常。
  2. 问题:碰撞检测异常,方块穿墙或提前固定。

    • 排查:重点检查checkCollision函数。添加调试输出(如果可用)或使用一个调试变量,在碰撞发生时点亮一个LED。手动计算一个临界情况(比如方块紧贴墙壁),单步跟踪checkCollision函数中每个格子的坐标计算和地图数组访问值,看是否与预期一致。
    • 常见错误:地图数组map的行列顺序与方块数组box的行列顺序在索引时弄反。记住:map[row][col],box[row][col]。在checkCollision中,i循环的是行(Y方向),j循环的是列(X方向)。
  3. 问题:游戏运行一段时间后卡死或复位。

    • 排查
      • 堆栈溢出:检查中断嵌套和局部变量大小。增大启动文件中的堆栈设置。
      • 数组越界:这是最可能的原因。严格检查所有数组访问,特别是map数组。确保curX, curY加上方块索引后不会超过MAP_TOTAL_ROWSMAP_TOTAL_COLS。哨兵边界就是防止越界的最后防线。
      • 死循环:检查clearFullLines函数中的循环条件,特别是在连续消行时,y++可能导致循环无法结束。
  4. 问题:按键反应迟钝或不灵。

    • 排查
      • 确认定时器中断频率是否合适(10-20ms为宜)。
      • 检查去抖逻辑。将按键历史记录输出到屏幕的一个角落,观察其变化,看是否稳定地从0xFF变为0x07。
      • 确认主循环的执行频率是否足够高,能及时处理key_pressed_flag
  5. 性能优化提示

    • 将频繁调用的函数(如checkCollision, 画点函数)用inline关键字内联(如果编译器支持)。
    • 对于固定值,如方块形状数组box[7][5][5],使用const关键字并将其放入Flash(code段),节省RAM。
    • 如果屏幕驱动支持“设置窗口-连续写数据”模式,一定要利用起来。在刷新一行或一个方块区域时,先设置好屏幕上的矩形窗口,然后连续发送像素数据,这比单点写快一个数量级。

从一块裸屏到一个能流畅运行的游戏,这个过程充满了挑战,但每一步问题的解决,都是对嵌入式系统开发理解的加深。当你按下按键,看到方块如预期般旋转、移动、消行时,那种成就感是纯粹的。这个项目麻雀虽小,五脏俱全,涵盖了从硬件接口、驱动编写、实时系统、状态机到简单算法和数据结构的方方面面,是一个非常好的综合练习。希望我的这些总结和踩过的坑,能帮你更顺畅地完成自己的嵌入式游戏项目。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询