基于51单片机的俄罗斯方块游戏开发:从硬件驱动到游戏逻辑的嵌入式实践

1. 项目概述:当经典游戏遇上嵌入式核心

如果你手边正好有一块吃灰的51单片机开发板,又恰好学过一点C语言,那么把经典的俄罗斯方块游戏搬上去,绝对是一个能让你成就感爆棚的“毕业设计”级项目。这不仅仅是点亮几个LED灯或者驱动个蜂鸣器那么简单,它融合了 实时控制、人机交互、图形显示和算法逻辑 等多个嵌入式开发的核心技能点。我当年就是用这个项目,彻底打通了从“知道C语言语法”到“能用C语言解决实际问题”的任督二脉。

这个项目的核心目标,是在资源极其有限的51单片机(通常指基于Intel 8051内核的微控制器,如STC89C52)上,完整实现俄罗斯方块游戏的所有基本功能: 方块的随机生成、旋转、下落、消行计分,以及通过按键控制 。最终,游戏画面会显示在一块常见的LCD1602字符液晶屏,或者更酷一点的LCD12864图形液晶屏上。文末我会附上经过多年教学和项目实践打磨的、可直接编译运行的完整源码,但在此之前,我更想带你深入“引擎盖”下面,看看每一个齿轮是如何咬合的。理解了原理,你不仅能复现,更能定制和优化。

2. 核心需求与系统设计拆解

要把一个完整的游戏塞进只有几KB RAM和几十KB Flash的51单片机里,我们不能像在PC上写程序那样随心所欲。必须进行精心的“瘦身”和模块化设计。

2.1 功能需求清单

一个最小可玩的俄罗斯方块游戏需要以下核心功能:

  1. 图形显示 :在液晶屏上绘制游戏区域(通常为10宽 x 20高)、当前下落方块、已堆积的方块以及下一个预览方块。
  2. 方块系统 :定义7种不同形状的方块(I, J, L, O, S, T, Z),每种方块有1到4种旋转状态。
  3. 游戏逻辑
    • 生成 :随机生成下一个方块。
    • 控制 :响应按键,实现方块的左移、右移、旋转、加速下落。
    • 碰撞检测 :判断方块移动或旋转后是否与边界或已堆积方块冲突。
    • 下落 :方块按固定时间间隔自动下落一行。
    • 锁定与消行 :方块触底后锁定,并检查是否有完整的行可以消除,消除后上方方块下落,并计分。
    • 游戏结束 :当新生成的方块无法放入游戏区域顶部时,游戏结束。

2.2 硬件平台选型与考量

为什么是51单片机?因为它简单、经典、资源明确,是理解底层硬件操作的绝佳平台。对于这个项目,我们需要:

  • 主控MCU :一片STC89C52RC(或类似型号)。它有8KB Flash,512字节RAM,完全够用。我更喜欢用STC的,因为串口下载方便。
  • 显示设备 LCD12864图形液晶屏(带ST7920控制器) 。这是关键选择。虽然LCD1602也能通过自定义字符“画”出简陋的图形,但效果远不如12864。12864有128x64个像素点,足以清晰地绘制出10x20的网格游戏区域,并留出空间显示分数、下一个方块预览等。选择带ST7920控制器的,是因为它支持并行和串行模式,且字库和绘图指令丰富。
  • 输入设备 :4个独立按键,分别对应“左移”、“右移”、“旋转”、“加速下落”。也可以使用矩阵键盘,但独立按键接线和编程更简单直观。
  • 其他 :蜂鸣器(用于消行、游戏结束的音效),以及必要的电阻、电容、杜邦线等。

注意 :在选择LCD12864时,务必确认其控制器型号是ST7920,并购买带背光的版本。不同控制器的指令集可能不同,直接照搬代码可能无法驱动。

2.3 软件架构设计

整个程序可以清晰地划分为以下几个模块,这种高内聚、低耦合的设计便于调试和后期维护:

  1. 硬件驱动层 lcd12864.c/.h ,封装所有液晶屏的初始化、清屏、画点等底层函数。
  2. 游戏引擎层
    • tetris.c/.h :核心游戏逻辑,包括游戏区域地图、方块定义、碰撞检测、消行逻辑、分数计算等。
    • timer.c/.h :利用单片机的定时器,产生精确的时基,用于控制方块自动下落的速度(游戏难度)。
  3. 输入输出层
    • key.c/.h :按键扫描与去抖处理。
    • buzzer.c/.h :控制蜂鸣器发出简单音效。
  4. 应用层 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 方块闪烁或残影严重

  • 现象 :方块移动时,旧位置的图像没有完全擦除,或者屏幕闪烁。
  • 原因 :这是图形编程的经典问题——“重绘”策略不当。
  • 解决
    1. 双缓冲(在51上成本高) :在内存中开辟一个和屏幕显存对应的缓冲区,所有绘图操作先在缓冲区完成,然后一次性刷到屏幕。51内存紧张,通常不用。
    2. 局部精确擦除 :记录方块上一帧的位置和形状 ( last_x, last_y, last_shape )。在绘制新方块前,先用背景色(0)在旧位置绘制一遍旧形状。然后再在新位置绘制新形状。确保 draw_block 函数既能画也能擦。
    3. 避免全屏清屏 :不要在每一帧都调用 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 ),或者数组定义太大。
  • 解决
    1. 在Keil C51的“Target Options”中,将“Memory Model”改为“Small: variables in DATA”,优化内存模型。
    2. 将大的常量数组(如方块形状表、字体点阵)用 code 关键字声明到代码存储区: const unsigned char code shape_table[7][4][4] = {...};
    3. 避免使用 printf ,自己写简单的 lcd_show_num() 函数显示数字。
    4. 开启编译优化(Level 2 或 Level 3)。

6.5 LCD显示乱码或全白/全黑

  • 现象 :屏幕不显示预期内容。
  • 原因 :初始化序列错误、时序不对、或数据/命令发送错误。
  • 排查步骤
    1. 检查硬件 :对比度电位器调节了吗?背光接了吗?电源电压稳定吗?
    2. 检查初始化代码 :严格按照数据手册的初始化流程,一条命令一条命令地核对,延时是否足够?很多屏需要较长的上电延时(>40ms)。
    3. 检查读写时序 :如果用的是模拟IO口时序,检查 EN 使能脉冲的宽度、建立和保持时间是否符合手册要求(通常是纳秒级,但51速度慢通常都能满足)。可以用逻辑分析仪或示波器抓一下波形。
    4. 检查PSB引脚 :PSB引脚决定是并行还是串行模式。如果程序是并行模式,PSB必须接高电平。这个坑我踩过无数次。
    5. 简化测试 :先不写游戏,写一个最简单的测试程序,就用来显示一行固定的文字或画一个矩形,确认驱动层是好的。

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               // 项目说明和接线图

快速开始

  1. 硬件连接 :按照 README.md 中的引脚定义表,将LCD、按键、蜂鸣器连接到你的51开发板。 务必确认LCD的PSB引脚接高电平(并行模式)
  2. 打开工程 :使用Keil uVision打开项目文件(或根据你的IDE导入文件)。
  3. 编译 :确保芯片型号选对(如STC89C52RC),编译项目。
  4. 下载 :通过STC-ISP等工具将生成的 .hex 文件下载到单片机。
  5. 上电运行 :如果一切正常,你将看到游戏开始界面。

个性化修改

  • 修改难度 :在 tetris.h 中调整 BASE_FALL_INTERVAL LEVEL_SPEED_STEP
  • 修改按键 :在 key.h 中修改按键的引脚定义。
  • 添加功能 :比如添加“暂停”功能,只需增加一个按键,在主循环中处理其状态,并在游戏更新逻辑中增加一个判断即可。
  • 更换显示 :如果想用OLED(I2C接口),你需要重写 lcd12864.c 中的底层驱动函数,但上层的 draw_block 等接口可以尽量保持不变,这就是模块化的好处。

这个项目麻雀虽小,五脏俱全。从底层硬件驱动到上层游戏逻辑,从状态机设计到中断管理,它几乎涵盖了嵌入式开发的所有基础概念。当你亲手让方块在单片机的屏幕上旋转、落下、消行时,那种对程序完全掌控的乐趣,是任何现成游戏都无法比拟的。希望这份详细的解析和源码,能成为你嵌入式开发之路上一块坚实的垫脚石。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值