1. 项目背景与系统架构设计

谷歌小恐龙游戏(Dino Runner)作为浏览器内置的经典离线游戏,其核心逻辑简洁而精巧:一个静态背景中,主角恐龙通过跳跃或蹲伏动作规避随机生成的障碍物,同时分数随时间持续累加。将这一游戏移植到嵌入式平台——特别是基于STM32F103C8T6(“Blue Pill”)与SSD1306 OLED显示屏的硬件组合上,不仅是一次图形显示能力的验证,更是一场对实时性、资源约束与状态机设计的综合考验。

本实现不依赖任何GUI框架或高级图形库,全程采用裸机编程风格,仅使用标准外设库(Standard Peripheral Library)驱动GPIO、SysTick与按键中断。系统运行于单线程主循环(main loop)结构,无RTOS介入,所有时间敏感操作(如帧刷新、按键消抖、动画步进)均由SysTick中断统一调度。这种设计决策源于对MCU资源的极致压缩:F103C8T6仅有20KB SRAM与64KB Flash,而SSD1306为128×64单色点阵屏,每帧显存仅需1024字节(128×64÷8)。任何额外的抽象层都会带来不可接受的内存开销与调度延迟。

整个系统被划分为五个正交模块: 显示渲染引擎 (负责OLED像素数据写入与缓冲区管理)、 素材资源管理器 (BMP图像数据解析与内存布局)、 状态机控制器 (维护游戏全局状态:RUNNING、JUMPING、DUCKING、BIG、GAME_OVER)、 输入处理单元 (三路独立按键的边沿检测与长按识别)以及 物理模拟器 (地面滚动、云彩飘移、障碍物生成与碰撞判定)。各模块通过明确定义的数据结构与函数接口交互,避免全局变量滥用,确保可维护性。

2. 硬件平台与外设初始化

2.1 OLED显示屏接口配置

SSD1306 OLED采用I²C总线通信,本设计选用PB6(SCL)与PB7(SDA)作为硬件I²C1引脚。初始化流程严格遵循SSD1306数据手册时序要求:

// I²C1 初始化(标准模式,100kHz)
I2C_InitTypeDef I2C_InitStruct;
I2C_InitStruct.I2C_ClockSpeed = 100000;
I2C_InitStruct.I2C_Mode = I2C_Mode_I2C;
I2C_InitStruct.I2C_DutyCycle = I2C_DutyCycle_2;
I2C_InitStruct.I2C_OwnAddress1 = 0x00;
I2C_InitStruct.I2C_Ack = I2C_Ack_Enable;
I2C_InitStruct.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
I2C_Init(I2C1, &I2C_InitStruct);
I2C_Cmd(I2C1, ENABLE);

OLED初始化序列包含关键寄存器配置:设置显示起始行(0x40)、段重映射(0xA0)、反向扫描(0xC8)、多路比率(0xA8, 0x3F)、对比度控制(0x81, 0xCF)、预充电周期(0xD9, 0xF1)、VCOMH取消(0xDB, 0x40)、振荡频率(0xD5, 0x80)及最终开启显示(0xAF)。此序列确保屏幕处于已知、稳定状态,避免上电后出现乱码或残影。

2.2 按键输入电路与GPIO配置

系统使用三颗独立轻触按键,分别对应跳跃(KEY2)、蹲伏(KEY1)与变大技能(KEY3)。按键一端接地,另一端接MCU GPIO,配置为上拉输入模式,利用内部弱上拉电阻(约40kΩ)简化外围电路:

// GPIOB 初始化(KEY1: PB0, KEY2: PB1, KEY3: PB2)
GPIO_InitTypeDef GPIO_InitStruct;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStruct);

此配置下,按键未按下时引脚读取为高电平(1),按下时为低电平(0)。软件层需实现硬件消抖,但关键的边沿检测(上升沿/下降沿)由SysTick中断周期性采样完成,避免了轮询带来的CPU占用率飙升。

2.3 SysTick定时器作为系统心跳

SysTick被配置为10ms中断周期,构成整个游戏的时间基准。该中断承担三项核心职责:按键状态快照、云彩移动步进、计分增量触发。10ms精度在人眼感知范围内足够平滑,同时为后续扩展(如更精细的跳跃轨迹)预留了计算余量:

// SysTick 初始化(10ms @ 72MHz)
if (SysTick_Config(SystemCoreClock / 100)) {
    while (1); // 配置失败死循环
}

中断服务函数(ISR)内执行的操作必须极简,仅更新标志位与计数器,所有耗时逻辑(如OLED刷新、碰撞检测)均在主循环中响应这些标志位后执行,这是保证实时性的黄金法则。

3. 图形资源管理与OLED渲染引擎

3.1 BMP素材转换与内存布局

游戏所有视觉元素(恐龙、仙人掌、飞鸟、云彩、地面纹理)均以24位BMP格式从Chrome浏览器开发者工具(F12 → Elements → 找到canvas元素 → 右键Save as Image)导出。随后使用PC端工具 PCtoLCD2002 进行转换,关键参数设置如下:
- 输出格式 :C数组( unsigned char
- 扫描方式 :水平扫描(从左到右,从上到下)
- 颜色模式 :单色(1bpp),黑色为1,白色为0
- 字节顺序 :MSB在前(符合SSD1306硬件要求)

转换后的数组直接嵌入固件,例如小恐龙站立帧 dino_stand[] 定义为:

const unsigned char dino_stand[100] = {
    0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
    0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
    // ... 后续90字节
};

每个数组长度 = 宽度 × 高度 ÷ 8。例如64×32像素图像占256字节(64×32÷8=256)。

3.2 显示缓冲区与双缓冲机制

SSD1306显存为128×64点阵,共1024字节,划分为8页(Page 0~7),每页128字节对应一行8像素高度。为避免画面撕裂,系统采用双缓冲策略:
- 前台缓冲区 frame_buffer[1024] ):直接映射至OLED显存,供硬件读取。
- 后台缓冲区 render_buffer[1024] ):软件绘图区域,所有元素(地面、云彩、恐龙、障碍)均在此合成。

每帧渲染开始时,先清空 render_buffer (全0),再按Z-order顺序绘制各图层:
1. 背景层 :静态云彩(仅当 cloud_x < 128 时绘制)
2. 中景层 :滚动地面( ground_x 偏移量控制)
3. 前景层 :动态元素(恐龙、障碍物、分数)

绘制完毕后,调用 OLED_Buffer_Update() 函数将 render_buffer 整块拷贝至 frame_buffer ,并通过I²C批量写入OLED显存。此过程耗时约3ms(1024字节@100kHz I²C),在10ms周期内完全可行。

3.3 动态图层实现原理

地面滚动(Ground Scrolling)

地面是一个宽度远超屏幕的无缝纹理(实际为256像素宽)。滚动效果通过 ground_x 变量控制其在128像素视口内的起始X坐标实现:

// ground_x 初始为0,每帧递减(向左滚动)
ground_x = (ground_x - 2) & 0xFF; // 2像素/帧速度,&0xFF实现循环
// 绘制:从 ground_x 开始,取128像素宽度
for (uint8_t x = 0; x < 128; x++) {
    uint8_t src_x = (ground_x + x) & 0xFF;
    // 将 ground_data[src_x] 的对应位写入 render_buffer[y][x]
}

ground_x 超出0~127范围时,通过位运算 &0xFF 自动回绕,形成无限滚动错觉。

云彩飘移(Cloud Drifting)

云彩宽度为26像素,小于屏幕宽度。其运动逻辑与地面不同:云彩从屏幕右侧( cloud_x = 128 )进入,向左移动直至完全消失( cloud_x < -26 )。为营造远近层次感,云彩移动速度设为地面的1/3(即每帧减0.67像素),通过 cloud_x 浮点变量配合整数截断实现:

cloud_x -= 0.67f; // 浮点运算,精度足够
if (cloud_x > -26 && cloud_x < 128) {
    // 计算整数X坐标并绘制
    int16_t draw_x = (int16_t)cloud_x;
    OLED_DrawBitmap(draw_x, 8, cloud_data, 26, 16);
}
障碍物生成(Obstacle Spawning)

障碍物(仙人掌、飞鸟)采用概率驱动的伪随机生成。系统维护一个 obstacle_timer 计数器,每100ms(即10次SysTick中断)检查一次:

if (++obstacle_timer >= 10) { // 100ms
    obstacle_timer = 0;
    if (rand() % 100 < 30) { // 30%概率生成
        // 随机选择类型:0=仙人掌(低), 1=仙人掌(高), 2=飞鸟(低), 3=飞鸟(高)
        uint8_t type = rand() % 4;
        // 初始化新障碍物结构体
        obstacles[obstacle_count].type = type;
        obstacles[obstacle_count].x = 128; // 从右侧进入
        obstacles[obstacle_count].y = (type < 2) ? 48 : 32; // Y坐标根据类型设定
        obstacle_count++;
    }
}

飞鸟Y坐标(32)高于仙人掌(48),模拟其飞行高度差异,为后续碰撞检测提供依据。

4. 游戏状态机与核心逻辑

4.1 全局状态枚举与转换规则

游戏状态并非简单布尔标志,而是一个精确控制所有行为的有限状态机(FSM)。定义如下:

typedef enum {
    STATE_IDLE,      // 启动后等待按键开始
    STATE_RUNNING,   // 正常奔跑,地面滚动,计分
    STATE_JUMPING,   // 腾空阶段,Y坐标按抛物线变化
    STATE_DUCKING,   // 蹲伏状态,高度减半,仅能躲避低障碍
    STATE_BIG,       // 变大无敌状态,碰撞免疫,持续时间倒计时
    STATE_GAME_OVER  // 碰撞触发,显示最终分数并暂停
} GameState;

状态转换严格依赖输入事件与物理条件:
- STATE_IDLE → STATE_RUNNING :任意按键首次按下(启动游戏)
- STATE_RUNNING → STATE_JUMPING :KEY2短按(上升沿检测)
- STATE_RUNNING → STATE_DUCKING :KEY1长按(持续低电平>300ms)
- STATE_RUNNING → STATE_BIG :KEY3按下且 power_flag == 1 (能量充足)
- STATE_JUMPING/STATE_DUCKING → STATE_RUNNING :跳跃落地或蹲伏结束
- STATE_BIG → STATE_RUNNING :能量耗尽( big_timer == 0
- STATE_RUNNING/STATE_JUMPING/STATE_DUCKING → STATE_GAME_OVER :碰撞检测为真

4.2 跳跃物理模型实现

跳跃非简单Y坐标线性增减,而是模拟重力加速度的抛物线运动。系统维护 jump_y (当前Y坐标)与 jump_vy (垂直速度)两个变量:

// 跳跃初始化(KEY2按下瞬间)
if (state == STATE_RUNNING) {
    state = STATE_JUMPING;
    jump_y = DINO_BASE_Y; // 基准Y=48
    jump_vy = -12;        // 初始向上速度
}

// 跳跃中每帧更新(主循环内)
if (state == STATE_JUMPING) {
    jump_y += jump_vy;    // 位置更新
    jump_vy += 2;         // 重力加速度:+2像素/帧²
    if (jump_y >= DINO_BASE_Y) { // 落地检测
        jump_y = DINO_BASE_Y;
        jump_vy = 0;
        state = STATE_RUNNING;
    }
}

此模型使跳跃呈现“快升慢降”的自然感, jump_vy 初始值-12与加速度+2经反复调试,确保跳跃高度(约24像素)与滞空时间(约12帧)符合原版手感。

4.3 碰撞检测算法

碰撞检测是游戏的核心判据,必须高效且无漏判。系统采用 轴对齐包围盒 (AABB)检测,对每个活动障碍物与恐龙进行矩形重叠判断:

// 恐龙碰撞箱(根据状态动态调整)
uint8_t dino_x = 10;
uint8_t dino_y, dino_h;
if (state == STATE_DUCKING) {
    dino_y = 56; dino_h = 8; // 蹲伏:Y=56, 高=8
} else if (state == STATE_BIG) {
    dino_y = 40; dino_h = 24; // 变大:Y=40, 高=24
} else {
    dino_y = 48; dino_h = 16; // 站立:Y=48, 高=16
}

// 障碍物碰撞箱(预定义)
uint8_t obs_x = obstacles[i].x;
uint8_t obs_y = obstacles[i].y;
uint8_t obs_w = (obstacles[i].type < 2) ? 24 : 32; // 仙人掌宽24,飞鸟宽32
uint8_t obs_h = (obstacles[i].type < 2) ? 16 : 16; // 高度均为16

// AABB检测:若两矩形在X和Y轴均有重叠,则碰撞
if (dino_x < obs_x + obs_w && 
    obs_x < dino_x + 16 && 
    dino_y < obs_y + obs_h && 
    obs_y < dino_y + dino_h) {
    state = STATE_GAME_OVER;
    break;
}

关键点在于恐龙宽度固定为16像素(X方向),而高度与Y坐标随状态动态变化,确保蹲伏时仅检测下半身,变大时扩大检测范围。障碍物宽度亦根据类型设定,飞鸟因有翅膀动画需更宽判定。

5. 输入处理与交互逻辑

5.1 三键协同工作模式

三颗按键非独立功能,而是构成一套协同交互系统:
- KEY1(PB0) :蹲伏键。短按无效,长按(>300ms)触发 STATE_DUCKING 。释放后自动切回 STATE_RUNNING 。长按期间恐龙保持低姿态,可穿越仙人掌但无法躲避飞鸟。
- KEY2(PB1) :跳跃键。仅响应上升沿(按键释放瞬间),无论按压时长。触发后立即进入 STATE_JUMPING ,即使按键仍被按住。此设计防止“粘连跳跃”,确保每次按键只产生一次跳跃。
- KEY3(PB2) :技能键。功能受 power_flag 约束。 power_flag 在分数≥100时置1,表示能量充能完毕。按下KEY3后, state 切换至 STATE_BIG ,恐龙尺寸放大,同时启动 big_timer 倒计时(初始值32,每帧减1)。

5.2 按键状态机实现

为精准捕获边沿与长按,SysTick中断内维护三个按键的状态变量:

// 中断内(每10ms)
static uint8_t key_last[3] = {1, 1, 1}; // 上次状态,1=释放
uint8_t key_now[3] = {
    GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_0), // KEY1
    GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1), // KEY2
    GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_2)  // KEY3
};

for (uint8_t i = 0; i < 3; i++) {
    if (key_last[i] == 1 && key_now[i] == 0) {
        key_edge_rise[i] = 1; // 记录上升沿(按键释放)
    }
    if (key_last[i] == 0 && key_now[i] == 0) {
        key_press_cnt[i]++; // 按压计时
    }
    key_last[i] = key_now[i];
}

主循环中据此判断:

// 主循环内
if (key_edge_rise[1]) { // KEY2上升沿
    key_edge_rise[1] = 0;
    if (state == STATE_RUNNING) {
        state = STATE_JUMPING;
        // ... 初始化跳跃
    }
}
if (key_press_cnt[0] > 30) { // KEY1长按300ms
    key_press_cnt[0] = 0;
    if (state == STATE_RUNNING) {
        state = STATE_DUCKING;
    }
}

5.3 反色与变大特效实现

屏幕反色(Invert Display)

当分数达到100并持续增长时,触发屏幕反色特效,增强视觉冲击。此操作非逐像素翻转,而是直接向SSD1306发送指令:

if (score >= 100 && !invert_flag) {
    invert_flag = 1;
    OLED_WriteCmd(0xA7); // 反色指令
}
if (score < 100 && invert_flag) {
    invert_flag = 0;
    OLED_WriteCmd(0xA6); // 正常显示指令
}

0xA7 指令使OLED所有像素极性反转,黑色变白、白色变黑,实现瞬时全屏反色,功耗与性能开销几乎为零。

变大状态(Big Mode)

变大并非单纯缩放图像,而是切换至预渲染的“大恐龙”素材,并动态调整碰撞箱与Y坐标:

// STATE_BIG 状态下的绘制
OLED_DrawBitmap(10, 40, dino_big_run[frame_index], 32, 24);
// 碰撞箱:X=10, Y=40, W=32, H=24
// 同时禁用所有碰撞检测(无敌)
if (state == STATE_BIG) {
    // 跳过所有障碍物碰撞检测循环
} else {
    // 执行常规碰撞检测
}

dino_big_run[] 是专为变大状态设计的32×24像素动画序列,比原始16×16大一倍,视觉上更具压迫感。无敌状态通过跳过碰撞检测逻辑实现,简洁高效。

6. 计分系统与游戏结束处理

6.1 分数累积与显示优化

分数( score )本质是游戏持续时间的量化,每100ms增加1分(即10分/秒)。为防止分数过快溢出,采用 uint16_t 类型,理论最大值65535(约1.8小时)。显示时需将数字转换为ASCII字符串,但直接调用 sprintf 会引入大量代码与栈空间。故采用手工除法:

void OLED_ShowNum(uint8_t x, uint8_t y, uint16_t num, uint8_t len) {
    uint8_t buf[5] = {0}; // 最多5位数
    uint8_t i = 0;
    do {
        buf[i++] = num % 10 + '0';
        num /= 10;
    } while (num && i < len);
    // 逆序显示
    for (uint8_t j = 0; j < i; j++) {
        OLED_ShowChar(x + (i-1-j)*8, y, buf[j]);
    }
}

调用 OLED_ShowNum(100, 0, score, 5) 即可在屏幕右上角(X=100,Y=0)显示右对齐的5位分数。

6.2 游戏结束(Game Over)流程

碰撞触发 STATE_GAME_OVER 后,系统进入冻结状态:
1. 停止所有动态逻辑 :地面滚动、云彩飘移、障碍物生成、分数累加全部暂停。
2. 显示Game Over文本 :在屏幕中央绘制“GAME OVER”字符串(使用自定义8×16字体)。
3. 显示最终分数 :在“GAME OVER”下方居中显示 score 值。
4. 等待重启 :检测任意按键按下,清除所有状态变量( score=0 , state=STATE_IDLE , power_flag=0 ),返回初始界面。

此流程确保玩家清晰获知游戏结果,并提供明确的重启入口,符合用户直觉。

7. 性能调优与常见问题排查

7.1 帧率稳定性保障

实测主循环执行时间约8.5ms(在72MHz主频下),留有1.5ms余量应对最坏情况(如大量障碍物同时绘制)。关键优化点:
- OLED写入批量化 :所有像素操作均先写入 render_buffer ,最后单次I²C传输1024字节,避免多次I²C Start/Stop开销。
- 数学运算精简 ground_x 滚动使用位运算 &0xFF 替代模运算 %256 ;分数显示避免浮点与 sprintf
- 条件编译裁剪 :调试时启用 #define DEBUG_MODE 打印关键变量,发布版移除。

7.2 已知缺陷与修复思路

云彩/障碍物突兀出现

现象:云彩与障碍物从屏幕右侧完全空白处突然全貌出现,缺乏渐入效果。
原因: cloud_x obs_x 初始值设为128,绘制时直接从X=128开始,而OLED仅显示X=0~127。当 x=128 时, OLED_DrawBitmap 函数未做边界检查,导致越界访问。
修复:在绘制前添加X坐标钳位:

int16_t draw_x = (int16_t)cloud_x;
if (draw_x < -26 || draw_x > 128) return; // 完全不可见则跳过
draw_x = (draw_x < 0) ? 0 : draw_x; // X<0时从0开始绘制,实现左边缘渐入
跳跃不丝滑

现象:跳跃轨迹存在明显阶梯感,缺乏流畅弧线。
原因: jump_vy 更新步长过大(+2),导致Y坐标变化粒度粗糙。
修复:改用定点数运算,提升垂直速度分辨率:

// 使用Q15定点数(15位小数)
int16_t jump_vy_q15 = -39322; // -12.0 * 32768
int16_t gravity_q15 = 65536;  // +2.0 * 32768
// 每帧:jump_y += jump_vy_q15 >> 15; jump_vy_q15 += gravity_q15;

此方案将Y坐标更新精度提升至1/32768像素,彻底消除阶梯效应。

按键响应延迟

现象:按键按下后需等待100ms才触发跳跃。
原因:SysTick中断仅每10ms采样一次,而跳跃检测在主循环中,若按键恰在两次中断之间按下,需等待下一个中断周期。
修复:在SysTick ISR中直接处理跳跃触发:

// 中断内
if (key_edge_rise[1] && state == STATE_RUNNING) {
    state = STATE_JUMPING;
    jump_y = DINO_BASE_Y;
    jump_vy_q15 = -39322;
    key_edge_rise[1] = 0;
}

此举将最大响应延迟从10ms降至接近0,实现“指哪打哪”的精准操控。

8. 工程实践总结与经验分享

这个小恐龙项目的完成,本质上是一次对嵌入式系统开发全流程的浓缩演练。从最初在Chrome中截图提取素材,到PC端工具转换BMP,再到MCU上手写OLED驱动、设计状态机、调试物理模型,每一个环节都踩过坑、流过汗。我曾连续三天卡在云彩闪烁问题上,最终发现是 cloud_x 变量类型误用为 uint8_t 导致溢出归零;也曾因未理解SSD1306的页地址模式,在绘制跨页图像时出现错位,浪费半天排查寄存器配置。

最深刻的体会是: 嵌入式开发的优雅,永远诞生于对底层硬件的敬畏与对资源的斤斤计较之中 。当看到自己手写的代码让一块小小的OLED屏上,那只像素恐龙真的开始奔跑、跳跃、躲避障碍,那种成就感远超任何高级框架的“Hello World”。它提醒我,技术的价值不在于堆砌多少炫酷特性,而在于能否用最朴素的工具,解决最具体的问题。

这个项目目前的400余行代码,虽未臻完美(如障碍物生成算法可升级为泊松盘分布以提升随机性,跳跃模型可引入阻尼系数模拟空气阻力),但它已是一个可运行、可理解、可修改的坚实基座。如果你正准备动手复刻,我的建议是:先专注实现 STATE_RUNNING STATE_JUMPING ,确保地面滚动与跳跃物理正确;再叠加 STATE_DUCKING 与碰撞检测;最后锦上添花加入反色与变大。切忌一步登天,嵌入式世界的魅力,永远藏在那一行行亲手敲下的、与硬件对话的代码里。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐