1. 项目概述:当经典游戏遇上嵌入式核心
如果你手边正好有一块吃灰的51单片机开发板,又恰好学过一点C语言,那么把经典的俄罗斯方块游戏搬上去,绝对是一个能让你成就感爆棚的“毕业设计”级项目。这不仅仅是点亮几个LED灯或者驱动个蜂鸣器那么简单,它融合了 实时控制、人机交互、图形显示和算法逻辑 等多个嵌入式开发的核心技能点。我当年就是用这个项目,彻底打通了从“知道C语言语法”到“能用C语言解决实际问题”的任督二脉。
这个项目的核心目标,是在资源极其有限的51单片机(通常指基于Intel 8051内核的微控制器,如STC89C52)上,完整实现俄罗斯方块游戏的所有基本功能: 方块的随机生成、旋转、下落、消行计分,以及通过按键控制 。最终,游戏画面会显示在一块常见的LCD1602字符液晶屏,或者更酷一点的LCD12864图形液晶屏上。文末我会附上经过多年教学和项目实践打磨的、可直接编译运行的完整源码,但在此之前,我更想带你深入“引擎盖”下面,看看每一个齿轮是如何咬合的。理解了原理,你不仅能复现,更能定制和优化。
2. 核心需求与系统设计拆解
要把一个完整的游戏塞进只有几KB RAM和几十KB Flash的51单片机里,我们不能像在PC上写程序那样随心所欲。必须进行精心的“瘦身”和模块化设计。
2.1 功能需求清单
一个最小可玩的俄罗斯方块游戏需要以下核心功能:
- 图形显示 :在液晶屏上绘制游戏区域(通常为10宽 x 20高)、当前下落方块、已堆积的方块以及下一个预览方块。
- 方块系统 :定义7种不同形状的方块(I, J, L, O, S, T, Z),每种方块有1到4种旋转状态。
-
游戏逻辑
:
- 生成 :随机生成下一个方块。
- 控制 :响应按键,实现方块的左移、右移、旋转、加速下落。
- 碰撞检测 :判断方块移动或旋转后是否与边界或已堆积方块冲突。
- 下落 :方块按固定时间间隔自动下落一行。
- 锁定与消行 :方块触底后锁定,并检查是否有完整的行可以消除,消除后上方方块下落,并计分。
- 游戏结束 :当新生成的方块无法放入游戏区域顶部时,游戏结束。
2.2 硬件平台选型与考量
为什么是51单片机?因为它简单、经典、资源明确,是理解底层硬件操作的绝佳平台。对于这个项目,我们需要:
- 主控MCU :一片STC89C52RC(或类似型号)。它有8KB Flash,512字节RAM,完全够用。我更喜欢用STC的,因为串口下载方便。
- 显示设备 : LCD12864图形液晶屏(带ST7920控制器) 。这是关键选择。虽然LCD1602也能通过自定义字符“画”出简陋的图形,但效果远不如12864。12864有128x64个像素点,足以清晰地绘制出10x20的网格游戏区域,并留出空间显示分数、下一个方块预览等。选择带ST7920控制器的,是因为它支持并行和串行模式,且字库和绘图指令丰富。
- 输入设备 :4个独立按键,分别对应“左移”、“右移”、“旋转”、“加速下落”。也可以使用矩阵键盘,但独立按键接线和编程更简单直观。
- 其他 :蜂鸣器(用于消行、游戏结束的音效),以及必要的电阻、电容、杜邦线等。
注意 :在选择LCD12864时,务必确认其控制器型号是ST7920,并购买带背光的版本。不同控制器的指令集可能不同,直接照搬代码可能无法驱动。
2.3 软件架构设计
整个程序可以清晰地划分为以下几个模块,这种高内聚、低耦合的设计便于调试和后期维护:
-
硬件驱动层
:
lcd12864.c/.h,封装所有液晶屏的初始化、清屏、画点等底层函数。 -
游戏引擎层
:
-
tetris.c/.h:核心游戏逻辑,包括游戏区域地图、方块定义、碰撞检测、消行逻辑、分数计算等。 -
timer.c/.h:利用单片机的定时器,产生精确的时基,用于控制方块自动下落的速度(游戏难度)。
-
-
输入输出层
:
-
key.c/.h:按键扫描与去抖处理。 -
buzzer.c/.h:控制蜂鸣器发出简单音效。
-
-
应用层
:
main.c,主循环,负责协调以上所有模块,构成游戏主循环。
3. 核心细节解析与实现要点
3.1 游戏区域与方块的数字化表示
这是整个项目的算法核心。我们不能真的在内存里存一个“图形”,而是要用数字来抽象表示。
游戏地图
:用一个二维数组
map[20][10]
来表示。数组的每一个元素对应游戏区域的一个“格子”。
0
表示该格子为空,
1
表示该格子已被方块占据(包括当前下落方块和已堆积的方块)。这种表示法使得碰撞检测变得异常简单——只需要判断目标位置的数组元素是否为
1
即可。
// 示例:定义游戏区域,高度20行,宽度10列
unsigned char game_map[20][10] = {0};
方块形状
:每种方块(如L形)在其4种旋转状态下的形状,可以用一个三维数组来预定义。更常见的是一种巧妙的“编码”法:用一个4x4的字节数组来表示一个方块,每个字节的每一位(bit)代表一个小方格。
1
表示有方块,
0
表示空。例如,一个“田”字方块(O型)可以表示为:
// 4x4网格,每个元素是一个字节的8位,这里只用了低4位表示4列
const unsigned char shape_O[4][4] = {
{0, 0, 0, 0},
{0, 1, 1, 0}, // 注意:1表示有方块
{0, 1, 1, 0},
{0, 0, 0, 0}
};
但更高效的是用一个
unsigned short
(16位)整数来表示一个4x4的方块,因为16位正好对应16个格子。通过位运算可以高效地进行旋转和碰撞检测。这是很多成熟实现的做法,为了理解方便,我们先从二维数组开始。
当前方块状态 :我们需要一个结构体来跟踪当前正在下落的方法的所有信息:
typedef struct {
unsigned char type; // 方块类型 (0-6 对应 I,J,L,O,S,T,Z)
unsigned char rotation; // 旋转状态 (0-3)
int pos_x; // 方块左上角在游戏地图中的列坐标
int pos_y; // 方块左上角在游戏地图中的行坐标
unsigned char shape[4][4]; // 当前旋转状态下的4x4形状数组
} CurrentBlock;
3.2 碰撞检测的实现逻辑
碰撞检测函数是游戏逻辑的“交警”。在任何试图移动或旋转方块的操作前,都必须先调用它进行校验。
// 检测将方块移动到 (new_x, new_y) 并旋转到 new_rotation 状态是否合法
// 返回 1 表示碰撞,0 表示合法
bit check_collision(CurrentBlock *block, int new_x, int new_y, unsigned char new_rotation) {
unsigned char test_shape[4][4];
get_block_shape(block->type, new_rotation, test_shape); // 获取旋转后的形状
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
if (test_shape[i][j]) { // 如果形状在此位置有方块
int map_x = new_x + j;
int map_y = new_y + i;
// 判断:是否超出左右边界?是否超出底部?是否与地图中已锁定的方块重叠?
if (map_x < 0 || map_x >= MAP_WIDTH || map_y >= MAP_HEIGHT) {
return 1; // 碰撞发生
}
if (map_y >= 0 && game_map[map_y][map_x]) { // 注意:map_y可能为负(方块还未完全进入区域)
return 1; // 与已堆积方块碰撞
}
}
}
}
return 0; // 合法移动
}
实操心得 :
map_y >= 0这个判断至关重要。因为方块的pos_y初始值可能是负数(表示方块从顶部外开始下落),在它完全进入可视区域前,我们只检测其已经进入区域的部分是否碰撞,否则游戏一开始就“撞墙”了。
3.3 基于定时器的游戏主循环与时间管理
51单片机是单线程的,我们必须利用中断来构建一个“伪多任务”环境。游戏的主循环应该尽可能快,只处理按键和渲染,而方块的下落这种需要定时发生的任务,交给定时器中断。
// 在 main.c 中
void main() {
sys_init(); // 初始化定时器、LCD、按键等
game_init(); // 初始化游戏地图、分数等
while(1) {
if (key_scan()) { // 扫描按键(非阻塞式)
process_input(); // 处理按键事件
}
if (render_flag) { // 渲染标志位,由定时器或事件置位
render_game(); // 将 game_map 和当前方块绘制到LCD
render_flag = 0;
}
if (game_over_flag) {
show_game_over();
// ... 等待重启
}
}
}
// 在 timer.c 中,定时器中断服务函数
void timer0_isr() interrupt 1 {
static unsigned int tick_counter = 0;
TH0 = 0xFC; TL0 = 0x67; // 重装初值,约1ms中断一次
tick_counter++;
if (tick_counter >= FALL_INTERVAL) { // 例如,每500ms触发一次下落
tick_counter = 0;
if (!move_block_down()) { // 尝试下落一格,如果失败(触底)
lock_block(); // 锁定当前方块
clear_lines(); // 检查并消除满行
spawn_new_block(); // 生成新方块
if (check_collision(...)) { // 判断新方块是否立即碰撞
game_over_flag = 1;
}
}
render_flag = 1; // 请求渲染
}
// 按键去抖计时也可以在这里管理
key_debounce_tick();
}
这种“事件驱动+状态机”的结构,是嵌入式游戏乃至许多实时系统的典型设计模式。
4. 关键模块的深度实现
4.1 LCD12864图形驱动与高效绘制
驱动LCD12864画图,本质上就是向控制器发送命令和数据,告诉它在哪个坐标点点亮像素。ST7920控制器将屏幕分为左右两半(各64x64),分别由两个“页”地址和列地址控制。
画点函数 :这是最基础的函数,所有图形(方块、边框)都基于它。
void lcd_draw_point(unsigned char x, unsigned char y, unsigned char color) {
unsigned char page, column;
unsigned char data_byte;
if (x >= 128 || y >= 64) return; // 边界检查
page = y / 8; // 每8个像素行为一“页”
column = x + (x < 64 ? 0 : 32); // 列地址计算,右半屏偏移32
// 1. 读取该地址当前的数据字节
lcd_set_pos(column, page);
data_byte = lcd_read_data();
// 2. 修改目标位
if (color) {
data_byte |= (1 << (y % 8)); // 置1,画点
} else {
data_byte &= ~(1 << (y % 8)); // 清0,擦除
}
// 3. 写回修改后的字节
lcd_set_pos(column, page);
lcd_write_data(data_byte);
}
避坑指南 :ST7920的“列地址”在左右半屏是连续的,但右半屏的起始列地址是0x80吗?不对!常见误区。实际上,在图形模式下,左半屏列地址是0x00-0x3F,右半屏是0x40-0x7F?也不对!根据数据手册,右半屏的列地址需要加上一个偏移量,通常是 左半屏最大列地址 + 8 ?经过实测和查阅多个厂商手册,最稳妥的方法是: 左半屏x坐标0-63对应列地址0-63;右半屏x坐标64-127对应列地址0-63,但在设置地址前,需要先通过命令选择右半屏的RAM 。很多驱动库会封装一个
set_pos(x, y)函数帮你处理这些细节。如果你移植代码时发现右半屏画图错位,问题九成出在这里。
绘制方块和游戏区域 :有了画点函数,我们可以绘制一个实心矩形来表示方块。游戏区域就是一个10x20网格的大矩形,内部画上浅浅的网格线会更有感觉。
void draw_block(int x, int y, int color) {
// x, y 是网格坐标,每个格子假设为3x3像素,留1像素间隙
int pixel_x = x * 4; // 计算屏幕像素坐标
int pixel_y = y * 4;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
lcd_draw_point(pixel_x + j, pixel_y + i, color);
}
}
}
每次游戏状态更新,我们不需要重绘整个屏幕,只需要 局部刷新 :擦除方块旧位置,绘制新位置。这需要记录上一帧方块的位置。
4.2 方块旋转算法的精妙实现
方块旋转是俄罗斯方块游戏的灵魂。对于4x4的方块表示法,旋转90度可以看作一个矩阵的转置再加行反转(取决于旋转方向)。
// 将4x4形状矩阵顺时针旋转90度
void rotate_shape_clockwise(unsigned char shape[4][4]) {
unsigned char temp[4][4];
// 先转置
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
temp[j][i] = shape[i][j];
}
}
// 再行反转(顺时针旋转)
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++) {
shape[i][j] = temp[i][3 - j];
}
}
}
但是,直接旋转可能会让方块“嵌”进墙里或已堆积的方块里(这叫“踢墙”)。商业游戏有复杂的“踢墙”规则来尝试多个偏移位置。在我们的简化版中,可以在旋转后如果发生碰撞,则尝试将方块向左、向右或向上微调一格,如果调整后合法,就允许旋转并移动。这能解决大部分不合理的旋转卡住问题。
4.3 消行与分数计算逻辑
当一行所有格子都为1时,这一行就需要被消除。消除后,上方所有行整体下落。
void clear_lines() {
int lines_cleared = 0;
for (int row = 0; row < MAP_HEIGHT; row++) {
int full = 1;
// 检查该行是否满
for (int col = 0; col < MAP_WIDTH; col++) {
if (game_map[row][col] == 0) {
full = 0;
break;
}
}
if (full) {
lines_cleared++;
// 将该行以上所有行下移一行
for (int r = row; r > 0; r--) {
for (int c = 0; c < MAP_WIDTH; c++) {
game_map[r][c] = game_map[r-1][c];
}
}
// 最顶行清零
for (int c = 0; c < MAP_WIDTH; c++) {
game_map[0][c] = 0;
}
// 因为当前行被新的上一行覆盖,需要重新检查这一行(此时row索引指向的是新移下来的行)
row--;
}
}
// 计分:一次消多行有奖励
if (lines_cleared > 0) {
switch(lines_cleared) {
case 1: score += 100; break;
case 2: score += 300; break; // 连消奖励
case 3: score += 500; break;
case 4: score += 800; break; // Tetris!
}
// 每消10行,提升一个等级,下落速度加快
total_lines_cleared += lines_cleared;
level = total_lines_cleared / 10 + 1;
fall_interval = BASE_INTERVAL - (level - 1) * SPEED_STEP; // 计算新的下落间隔
update_display(); // 更新分数和等级显示
play_clear_sound(); // 播放音效
}
}
5. 系统整合与主循环构建
将上述所有模块整合进
main.c
,就构成了游戏的完整心跳。
void main() {
// 初始化
init_lcd(); // 液晶初始化
init_timer0(); // 定时器0初始化,用于游戏时钟和按键扫描
init_keys(); // 按键IO口初始化
init_buzzer(); // 蜂鸣器初始化
EA = 1; // 开启总中断
// 游戏开始界面
show_start_screen();
while(1) {
// 状态机:游戏进行中
while(!game_over) {
// 1. 处理输入(非阻塞,在中断中已扫描,这里读取结果)
key_value = get_key();
if (key_value != KEY_NONE) {
handle_input(key_value); // 处理移动、旋转、加速下落
render_flag = 1;
}
// 2. 处理游戏逻辑(主要由定时器中断驱动,这里只检查标志位)
if (logic_update_flag) {
logic_update_flag = 0;
// 更新游戏状态,这个函数可能被定时器中断调用,也可能被按键处理函数调用
// 为了数据安全,如果中断和主循环都可能修改共享数据,需要考虑关中断保护
update_game_logic();
}
// 3. 渲染(仅在需要时)
if (render_flag) {
render_game_screen(); // 绘制游戏区域、方块、分数、下一个预览
render_flag = 0;
}
}
// 状态机:游戏结束
if (game_over) {
show_game_over_screen();
if (restart_key_pressed()) {
reset_game();
game_over = 0;
}
}
}
}
6. 常见问题与调试实录
在实现过程中,你几乎一定会遇到下面这些问题。我把我的踩坑记录分享给你,希望能帮你节省几个小时甚至几天的时间。
6.1 方块闪烁或残影严重
- 现象 :方块移动时,旧位置的图像没有完全擦除,或者屏幕闪烁。
- 原因 :这是图形编程的经典问题——“重绘”策略不当。
-
解决
:
- 双缓冲(在51上成本高) :在内存中开辟一个和屏幕显存对应的缓冲区,所有绘图操作先在缓冲区完成,然后一次性刷到屏幕。51内存紧张,通常不用。
-
局部精确擦除
:记录方块上一帧的位置和形状 (
last_x, last_y, last_shape)。在绘制新方块前,先用背景色(0)在旧位置绘制一遍旧形状。然后再在新位置绘制新形状。确保draw_block函数既能画也能擦。 -
避免全屏清屏
:不要在每一帧都调用
lcd_clear(),这会导致严重闪烁。只更新变化的部分。
6.2 按键响应不灵或连击
- 现象 :按一下键没反应,或者一下移动好几格。
- 原因 :按键抖动和主循环扫描频率不匹配。
-
解决
:
- 硬件去抖 :按键并联一个0.1uF电容,效果有限。
-
软件去抖(推荐)
:在定时器中断(比如每10ms)中扫描按键状态,采用“状态机”法:
// 按键状态机 #define KEY_STATE_RELEASED 0 #define KEY_STATE_DEBOUNCE 1 #define KEY_STATE_PRESSED 2 #define KEY_STATE_HOLD 3 // 在定时中断中 if (KEY_PIN == 0) { // 检测到低电平(按下) if (key_state == KEY_STATE_RELEASED) { key_state = KEY_STATE_DEBOUNCE; debounce_timer = DEBOUNCE_TIME; // 设置去抖计时,如5个周期(50ms) } else if (key_state == KEY_STATE_DEBOUNCE) { if (--debounce_timer == 0) { key_state = KEY_STATE_PRESSED; key_event = KEY_EVENT_PRESS; // 产生一次按下事件 } } else if (key_state == KEY_STATE_PRESSED) { key_state = KEY_STATE_HOLD; hold_timer = HOLD_DELAY; // 设置长按判定延时 } else if (key_state == KEY_STATE_HOLD) { if (--hold_timer == 0) { key_event = KEY_EVENT_HOLD; // 产生连发事件 hold_timer = HOLD_INTERVAL; // 设置连发间隔 } } } else { // 按键释放 key_state = KEY_STATE_RELEASED; } -
主循环中
,只需检查
key_event变量,而不用反复读取IO口。
6.3 游戏运行速度不稳定或过快
- 现象 :方块下落忽快忽慢,或者快得看不清。
- 原因 :游戏速度依赖于主循环的执行速度,而主循环中如果渲染或逻辑计算耗时不稳定,就会导致速度波动。
- 解决 : 将所有与时间相关的逻辑(如下落、动画)剥离到定时器中断中 ,如3.3节所述。主循环只负责处理事件和渲染请求。确保定时器中断服务函数执行时间尽可能短,不要在里面做复杂计算(如消行检测),只设置标志位,让主循环去处理。
6.4 编译后程序太大,下载不进单片机
- 现象 :代码编译后超过单片机Flash容量(如STC89C52是8KB)。
-
原因
:可能包含了大型库函数(如浮点运算、
printf),或者数组定义太大。 -
解决
:
- 在Keil C51的“Target Options”中,将“Memory Model”改为“Small: variables in DATA”,优化内存模型。
-
将大的常量数组(如方块形状表、字体点阵)用
code关键字声明到代码存储区:const unsigned char code shape_table[7][4][4] = {...}; -
避免使用
printf,自己写简单的lcd_show_num()函数显示数字。 - 开启编译优化(Level 2 或 Level 3)。
6.5 LCD显示乱码或全白/全黑
- 现象 :屏幕不显示预期内容。
- 原因 :初始化序列错误、时序不对、或数据/命令发送错误。
-
排查步骤
:
- 检查硬件 :对比度电位器调节了吗?背光接了吗?电源电压稳定吗?
- 检查初始化代码 :严格按照数据手册的初始化流程,一条命令一条命令地核对,延时是否足够?很多屏需要较长的上电延时(>40ms)。
-
检查读写时序
:如果用的是模拟IO口时序,检查
EN使能脉冲的宽度、建立和保持时间是否符合手册要求(通常是纳秒级,但51速度慢通常都能满足)。可以用逻辑分析仪或示波器抓一下波形。 - 检查PSB引脚 :PSB引脚决定是并行还是串行模式。如果程序是并行模式,PSB必须接高电平。这个坑我踩过无数次。
- 简化测试 :先不写游戏,写一个最简单的测试程序,就用来显示一行固定的文字或画一个矩形,确认驱动层是好的。
7. 项目源码结构与使用指南
我将完整的项目源码打包,你可以直接使用。以下是核心文件清单及说明:
51_Tetris_Project/
├── main.c // 主程序,游戏主循环和状态机
├── tetris.c / tetris.h // 游戏核心逻辑:地图、方块、碰撞、消行
├── lcd12864.c / lcd12864.h // LCD12864 (ST7920) 驱动
├── timer.c / timer.h // 定时器配置与中断服务
├── key.c / key.h // 独立按键扫描与状态机去抖
├── buzzer.c / buzzer.h // 蜂鸣器控制,简单音效
├── delay.c / delay.h // 简单的延时函数(用于初始化)
└── README.md // 项目说明和接线图
快速开始 :
-
硬件连接
:按照
README.md中的引脚定义表,将LCD、按键、蜂鸣器连接到你的51开发板。 务必确认LCD的PSB引脚接高电平(并行模式) 。 - 打开工程 :使用Keil uVision打开项目文件(或根据你的IDE导入文件)。
- 编译 :确保芯片型号选对(如STC89C52RC),编译项目。
-
下载
:通过STC-ISP等工具将生成的
.hex文件下载到单片机。 - 上电运行 :如果一切正常,你将看到游戏开始界面。
个性化修改 :
-
修改难度
:在
tetris.h中调整BASE_FALL_INTERVAL和LEVEL_SPEED_STEP。 -
修改按键
:在
key.h中修改按键的引脚定义。 - 添加功能 :比如添加“暂停”功能,只需增加一个按键,在主循环中处理其状态,并在游戏更新逻辑中增加一个判断即可。
-
更换显示
:如果想用OLED(I2C接口),你需要重写
lcd12864.c中的底层驱动函数,但上层的draw_block等接口可以尽量保持不变,这就是模块化的好处。
这个项目麻雀虽小,五脏俱全。从底层硬件驱动到上层游戏逻辑,从状态机设计到中断管理,它几乎涵盖了嵌入式开发的所有基础概念。当你亲手让方块在单片机的屏幕上旋转、落下、消行时,那种对程序完全掌控的乐趣,是任何现成游戏都无法比拟的。希望这份详细的解析和源码,能成为你嵌入式开发之路上一块坚实的垫脚石。

283

被折叠的 条评论
为什么被折叠?



