1. 项目背景与硬件选型
第一次用51单片机做游戏开发时,我对着12864液晶屏发呆了整整三天。这块巴掌大的屏幕能跑贪吃蛇?事实证明不仅能跑,还能玩出花样。STC89C51这颗老当益壮的芯片,配合128×64点阵的LCD,构成了这个项目的硬件基础。
选择这套组合有三个原因:首先是成本,51单片机开发板加12864屏总价不超过50元;其次是教学价值,通过这个项目能掌握GPIO控制、定时器中断、液晶驱动等核心技能;最后是社区支持,光是STC官网就有上百页的中文资料。实际开发中发现,STC89C52的8K Flash完全够用,运行时的RAM消耗约200字节。
硬件连接要注意几个关键点:LCD的PSB引脚要接高电平选择并行模式,VO引脚接10K电位器调节对比度。我最初因为PSB接错导致屏幕白屏,用万用表量了半天才发现问题。建议按这个方式接线:
// LCD12864并行接口定义 sbit LCD_RS = P2^0; // 数据/命令选择 sbit LCD_RW = P2^1; // 读写选择 sbit LCD_EN = P2^2; // 使能信号 #define DATA_PORT P0 // 数据总线2. 液晶驱动开发实录
驱动12864是个技术活,特别是第一次接触ST7920控制器时,那些寄存器配置看得人头皮发麻。经过反复测试,总结出最稳定的初始化序列:
void LCD_Init() { DelayMs(50); // 上电延时 WriteCmd(0x30); // 基本指令集 WriteCmd(0x0C); // 显示开,关游标 WriteCmd(0x01); // 清屏 WriteCmd(0x06); // 地址指针自动加1 }实际调试中发现几个坑:一是使能信号EN的下降沿触发需要至少1us延时,二是写命令前必须检测忙标志。有次因为没加忙检测,导致初始化随机失败,后来用逻辑分析仪抓波形才定位问题。
图形绘制函数是游戏的核心,我优化过的画点函数比标准库快3倍:
void DrawPixel(uchar x, uchar y, uchar color) { uchar page = y / 8; uchar bitmask = 1 << (y % 8); SetAddress(x, page); if(color) LCD_Data |= bitmask; else LCD_Data &= ~bitmask; }3. 游戏逻辑精要设计
贪吃蛇的本质是链表操作,但在51上我用数组模拟更节省资源。定义蛇身结构体时,实测发现用单字节存储坐标足够(12864分辨率下x:0-127, y:0-63):
struct { uchar x[MAX_LEN]; uchar y[MAX_LEN]; uchar length; uchar direction; // 0-3表示上下左右 } snake;移动算法采用"去尾添头"策略,配合方向键处理:
void MoveSnake() { // 保留尾部坐标用于擦除 uchar lastX = snake.x[snake.length-1]; uchar lastY = snake.y[snake.length-1]; // 身体跟随 for(uchar i=snake.length-1; i>0; i--) { snake.x[i] = snake.x[i-1]; snake.y[i] = snake.y[i-1]; } // 计算新头部 switch(snake.direction) { case UP: snake.y[0]--; break; case DOWN: snake.y[0]++; break; case LEFT: snake.x[0]--; break; case RIGHT: snake.x[0]++; break; } // 重绘 DrawPixel(lastX, lastY, 0); // 擦除尾部 DrawPixel(snake.x[0], snake.y[0], 1); // 绘制头部 }4. 人机交互优化技巧
按键处理采用状态机模型,解决了矩阵键盘的抖动问题。定义五个按键:上下左右控制方向,中间键暂停/开始。消抖逻辑是这样的:
uchar GetKey() { static uchar lastState = 0xFF; uchar currState = P1 & 0x1F; // 读取P1.0-P1.4 if(currState != lastState) { DelayMs(10); // 延时去抖 currState = P1 & 0x1F; lastState = currState; } if(currState != 0x1F) { while((P1 & 0x1F) != 0x1F); // 等待释放 return currState; } return 0xFF; // 无按键 }游戏难度通过定时器中断动态调整,等级越高中断周期越短:
void Timer0_Init() { TMOD |= 0x01; // 模式1 TH0 = (65536 - 30000) / 256; // 初始30ms TL0 = (65536 - 30000) % 256; ET0 = 1; EA = 1; TR0 = 1; } void Timer0_ISR() interrupt 1 { static uchar speed = 1; if(++speed >= (10 - level)) { speed = 0; needMove = 1; // 主循环检测该标志 } // 重装初值 TH0 = (65536 - 30000) / 256; TL0 = (65536 - 30000) % 256; }5. Proteus仿真要点
仿真时发现LCD模型响应速度比实物慢,解决办法是降低仿真步长至1ms。关键器件参数:
- 单片机频率:11.0592MHz
- LCD型号:LGM12641BS1R(带ST7920)
- 按键:BUTTON元件加10K上拉
仿真电路要特别注意电源去耦,我在VCC和GND之间加了100nF电容,否则会出现随机复位现象。完整的电路连接可以参考这个简图:
+5V ──┬── LCD_VCC │ ┌┴┐ │ │ 10K └┬┘ ├── LCD_PSB (高电平) │ ┌┴┐ │ │ 10K电位器 └┬┘ GND ──┴── LCD_VO6. 性能优化实战
在51这样的8位机上做图形游戏,优化至关重要。通过以下手段将帧率从5fps提升到15fps:
- 局部刷新:只更新蛇头和蛇尾,而非全屏重绘
- 查表法:将常用图形(如数字、边框)预存到code区
- 汇编内联:对关键函数用#pragma asm优化
最有效的还是显示优化,改进后的刷新逻辑:
void UpdateScreen() { // 只刷新变化部分 if(needRefresh) { DrawBorder(); // 边框只需绘制一次 needRefresh = 0; } // 蛇身动态刷新 DrawPixel(oldTailX, oldTailY, 0); DrawPixel(snake.x[0], snake.y[0], 1); // 食物刷新 if(food.eaten) { DrawPixel(food.x, food.y, 1); food.eaten = 0; } }7. 常见问题解决方案
问题1:屏幕出现鬼影
- 检查延时是否满足时序要求
- 确保每次写命令前检测忙标志
- 在EN下降沿后加1us延时
问题2:蛇身断裂
- 检查数组越界问题
- 确认移动算法没有漏掉中间节点
- 增加碰撞检测日志输出
问题3:按键响应迟钝
- 降低去抖延时(10-20ms为宜)
- 改用中断方式检测按键
- 检查定时器中断优先级
有个特别隐蔽的BUG:当蛇长超过32节时会出现随机死机。最后发现是数组索引用了char型,超过127后溢出。改为uint8_t后问题解决:
typedef unsigned char uint8_t; uint8_t snakeLength; // 0-2558. 功能扩展思路
基础功能完成后,可以尝试这些进阶改造:
- 多级菜单:用状态机实现开始/暂停/设置界面
- 存档功能:利用EEPROM保存最高分
- 音效输出:通过PWM驱动蜂鸣器
- 双人对战:增加第二条蛇的控制逻辑
我实现的存档功能代码片段:
void SaveHighScore() { IAP_CONTR = 0x80; // 开启IAP IAP_CMD = 0x02; // 写模式 IAP_ADDRH = 0x00; // EEPROM地址 IAP_ADDRL = 0x00; IAP_DATA = highScore; IAP_TRIG = 0x5A; IAP_TRIG = 0xA5; IAP_CONTR = 0x00; // 关闭IAP }这个项目最让我自豪的是最终代码仅占用6.5KB Flash,RAM使用率不到60%。源码包已整理好包含:Keil工程文件、Proteus仿真图、完整注释的驱动程序。