基于STM32+OLED 手搓一个谷歌小恐龙跳跃游戏,含完整碰撞检测原理
前言:这学期嵌入式课程设计,我选了做个小游戏练练手。一开始觉得不就是个恐龙跳一跳嘛,能有多难?结果真写起来,光是碰撞检测就卡了我两天…… 踩了不少坑,这里把整个项目的思路和代码都整理出来,希望能帮到同样在做课设的小伙伴。
一、项目效果
先看基于 STM32F103C8T6 最小系统板 + 0.96 寸 OLED 屏幕实现的谷歌浏览器小恐龙跳跃游戏:
恐龙自动奔跑动画(两帧切换)
按键跳跃(正弦抛物线,手感丝滑)
随机障碍物(三种仙人掌样式)
地面无限滚动 + 云朵背景
实时计分系统
AABB 矩形碰撞检测,撞上直接 Game Over
硬件就一块最小系统板加个 OLED,接线超简单,I2C 接口四根线搞定。
二、整体架构思路
整个游戏的核心其实就三件事:
- 画面怎么动起来 —— 所有物体坐标每一帧都变
- 恐龙怎么跳起来 —— 用数学函数模拟物理跳跃
- 怎么判断撞上了 —— 矩形包围盒碰撞算法
我把代码分成了几个模块:
物体坐标结构体(统一碰撞检测)
画面绘制模块(地面 / 云朵 / 恐龙 / 障碍物)
游戏逻辑模块(Tick 时钟 + 跳跃 + 计分)
碰撞检测模块(AABB 算法)
就这四个主要模块
三、核心模块详解
- 物体坐标结构体 —— 碰撞检测的基础
一开始我想的是,碰撞嘛,对比像素点不就行了?后来发现单片机根本扛不住像素级检测,太费时间了。
于是用了包围盒的思路:给每个物体套一个矩形,只判断矩形有没有重叠。
struct Object_Position{
uint8_t min_x,min_y,max_x,max_y;
};
就这么简单一个结构体,恐龙和障碍物各定义一个实例,碰撞检测全靠它。
小 Tips:包围盒不用完全贴死图像,稍微留一点余量,玩家体验会更好,不会 "擦边就死"。
- 场景滚动 —— 地面和云朵怎么 "动"
地面滚动是我觉得最巧妙的部分,原理其实超简单:
- 准备一张比屏幕宽的地面图片(我做了 256 像素宽)
- 用一个偏移量 Ground_Pos 不断自增
- 每次从数组里截取 128 像素(屏幕宽度)显示
- 偏移量超过 256 就归零,实现无缝循环
地面不断地往左移动,(地面像素不断循环)
uint16_t Ground_Pos;
void Show_Ground(void){
if(Ground_Pos<128){
for(uint8_t i=0;i<128;i++){
OLED_DisplayBuf[7][i]=Ground[i+Ground_Pos];
}
}else{
for(uint8_t i=0;i<255-Ground_Pos;i++){
OLED_DisplayBuf[7][i]=Ground[i+Ground_Pos];
}
for(uint8_t i=255-Ground_Pos;i<128;i++){
OLED_DisplayBuf[7][i]=Ground[i-(255-Ground_Pos)];
}
云朵也是一模一样的逻辑,就是速度慢一点,营造层次感。
- 恐龙跳跃 —— 正弦函数 yyds
跳跃怎么实现?一开始我想的是直接写死高度:先上升再下降。但那样跳起来很生硬,像机器人。
后来想到了正弦函数,0 到 π 之间不就是一个完美的抛物线吗!
jump_Pos = 28 * sin(pi * jump_t / 1000.0f);
- jump_t 是跳跃计时器,从 0 加到 1000
- 除以 1000 再乘 π,正好映射到 0 ~ π 的范围
- 乘 28 是跳跃最大高度(像素)
这样跳起来的轨迹就是:慢起→最高点最慢→加速下落,和真实物理感特别像。
踩坑记录:一开始没加跳跃状态锁,在空中按按键会重新起跳,直接卡出 bug 飞天了。一定要加 dino_jump_flag 判断,只有在地面才能再次起跳!
4.障碍物生成 —— 随机但可控
障碍物和地面同步向左移动,跑出屏幕后重新生成:
int8_t Barrier_flag;
uint8_t Barrier_Pos;
struct Object_Position barrier;
void Show_Barrier(){
if(Barrier_Pos>=143){
Barrier_flag=rand()%3;
}
四、重点来了:AABB 碰撞检测原理
四种条件必须同时达成,才会被判定为碰撞。
这是整个游戏的核心,也是我卡最久的地方。
什么是 AABB?
AABB = Axis-Aligned Bounding Box,轴对齐包围盒。
说白了就是:两个边都和坐标轴平行的矩形,判断它们有没有重叠。
怎么判断碰撞?
直接想 "两个矩形什么时候重叠" 有点绕,换个思路:什么时候一定不碰撞?
两个矩形不碰撞只有四种情况:
- 矩形 A 在 矩形 B 左边 —— A 的右边 < B 的左边
- 矩形 A 在 矩形 B 右边 —— A 的左边 > B 的右边
- 矩形 A 在 矩形 B 上边 —— A 的下边 < B 的上边
- 矩形 A 在 矩形 B 下边 —— A 的上边 > B 的下边
反过来:四个条件都不满足 → 两个矩形重叠 → 发生碰撞!
对应代码:
int isColliding(struct Object_Position *a, struct Object_Position *b)
{
// 完整括号 + 成员名正确
if( (a->max_x > b->min_x) && (a->min_x < b->max_x) && (a->max_y > b->min_y) && (a->min_y < b->max_y) )
{
OLED_Clear();
OLED_ShowString(28, 24, "Game Over", OLED_8X16);
OLED_Update();
Delay_s(1);
OLED_Clear();
OLED_Update();
return 1;
}
return 0;
}
就这四行判断,搞定碰撞检测。
用大白话再解释一遍:
- 恐龙的右手 伸过了 障碍物的左手
- 恐龙的左手 还在 障碍物的右手 左边 → 左右方向已经交叉了
- 上下方向同理 → 横竖都交叉 = 两个方块叠在一起 = 撞上了
为什么用 AABB 不用像素级检测?
对比项 AABB 矩形碰撞 像素级碰撞 运算量 4 次整数比较 上百次像素逐点对比 单片机压力 几乎为零 严重卡顿 代码复杂度 简单易懂 复杂难维护 游戏体验 足够用 没必要
五、游戏心跳:Tick 函数
整个游戏的节奏全靠 Dino_Tick () 这个函数统一控制,它就像游戏的心脏,每调用一次,时间往前走一格。
void Dino_Tick(){
static uint8_t Score_Count,Ground_Count,Cloud_Count;
Score_Count++;
Ground_Count++;
Cloud_Count++;
if(Score_Count>=100){
Score_Count=0;
Score++;
}
if(Ground_Count>=20){
Ground_Count=0;
Ground_Pos++;
Barrier_Pos++;
if(Ground_Pos>=256){
Ground_Pos=0;
}
if(Barrier_Pos>=144){
Barrier_Pos=0;
}
}
if(Cloud_Count>=50){
Cloud_Count=0;
Cloud_Pos++;
if(Cloud_Pos>=200){
Cloud_Pos=0;
}
}
if(dino_jump_flag==1){
jump_t++;
if(jump_t>=1000){
jump_t=0;
dino_jump_flag=0;
}
}
}
不同物体用不同的计数器阈值,实现不同的移动速度,画面就有了层次感。这个函数一般放在定时器中断里调用,保证游戏速度稳定,不会因为 CPU 负载变化而忽快忽慢。
六、主游戏循环
整个游戏的主循环其实非常简洁:
int DinoGame_Animation() {
while (1) {
OLED_Clear();
Show_Score();
Show_Ground();
Show_Barrier();
Show_Cloud();
Show_Dino();
OLED_Update();
if (isColliding(&dino, &barrier)) {
return 0;
}
}
}
每一帧的流程就是:清屏 → 把所有东西画上去 → 刷新屏幕 → 判断死没死。
经典的游戏循环范式,学过 Unity 或者其他游戏引擎的同学应该很熟悉。
七、我踩过的那些坑
坑 1:碰撞检测偶尔失灵
现象:有时候明明撞上了,恐龙却穿过去了。
原因:障碍物的坐标是在 Show_Barrier () 里更新的,如果调用顺序不对,就会出现 "画面已经更新了,但碰撞坐标还没更新" 的情况。
解决:把坐标计算和画面绘制分开,先算好所有位置,再统一画图,最后检测碰撞。
坑 2:恐龙可以无限连跳
现象:在空中狂按按键,恐龙越飞越高。
原因:没有判断当前是否在跳跃状态,按键直接重置跳跃时间。
解决:加跳跃状态锁,只有 dino_jump_flag == 0(站在地上)时才能起跳。
坑 3:画面闪烁严重
现象:屏幕肉眼可见地闪。
原因:边画边刷新,或者清屏和刷新之间间隔太长。
解决:先在缓冲区里把所有东西都画好,最后只调用一次 OLED_Update () 整屏刷新。
八、完整核心代码
/**
******************************************************************************
* @file dino_game.c
* @brief STM32 OLED 谷歌小恐龙跳跃游戏
* @hardware STM32F103C8T6 + 0.96寸 OLED (I2C)
* @features 自动奔跑、按键跳跃、随机障碍物、计分、AABB碰撞检测
******************************************************************************
*/
#include "stm32f10x.h"
#include "OLED.h"
#include "key.h"
#include "stdlib.h"
#include "math.h"
#include "Delay.h"
/* ==================== 宏定义 ==================== */
#define OLED_WIDTH 128 // OLED屏幕宽度
#define OLED_HEIGHT 64 // OLED屏幕高度
#define GROUND_WIDTH 256 // 地面图片总宽度
#define GROUND_ROW 7 // 地面所在行(第8行,索引7)
#define GROUND_SPEED 20 // 地面滚动速度(计数阈值)
#define DINO_WIDTH 16 // 恐龙宽度
#define DINO_HEIGHT 18 // 恐龙高度
#define DINO_X 0 // 恐龙固定X坐标
#define DINO_GROUND_Y 44 // 恐龙地面Y坐标
#define JUMP_MAX_HEIGHT 28 // 跳跃最大高度
#define JUMP_DURATION 1000 // 跳跃持续时间(tick数)
#define BARRIER_WIDTH 16 // 障碍物宽度
#define BARRIER_HEIGHT 18 // 障碍物高度
#define BARRIER_Y 44 // 障碍物Y坐标
#define BARRIER_RESET_POS 144 // 障碍物重置位置
#define BARRIER_TYPE_COUNT 3 // 障碍物种类数
#define CLOUD_WIDTH 16 // 云朵宽度
#define CLOUD_HEIGHT 8 // 云朵高度
#define CLOUD_Y 9 // 云朵Y坐标
#define CLOUD_RESET_POS 200 // 云朵重置位置
#define CLOUD_SPEED 50 // 云朵移动速度(计数阈值)
#define SCORE_SPEED 100 // 加分速度(计数阈值)
#define SCORE_X 98 // 分数显示X坐标
#define SCORE_Y 0 // 分数显示Y坐标
#define SCORE_DIGITS 5 // 分数显示位数
#define GAME_OVER_X 28 // GameOver文字X坐标
#define GAME_OVER_Y 24 // GameOver文字Y坐标
/* ==================== 类型定义 ==================== */
/**
* @brief 物体位置结构体(AABB包围盒,用于碰撞检测)
*/
struct Object_Position
{
uint8_t min_x; // 矩形左边界
uint8_t min_y; // 矩形上边界
uint8_t max_x; // 矩形右边界
uint8_t max_y; // 矩形下边界
};
/* ==================== 全局变量 ==================== */
int Score; // 游戏分数
uint16_t Ground_Pos; // 地面滚动偏移量
uint8_t Barrier_flag; // 障碍物样式(0~2)
uint8_t Barrier_Pos; // 障碍物位置偏移
uint8_t Cloud_Pos = 0; // 云朵位置偏移
uint8_t dino_jump_flag = 0; // 跳跃状态:0=奔跑,1=跳跃中
uint16_t jump_t; // 跳跃计时器
uint8_t jump_Pos; // 当前跳跃高度
struct Object_Position barrier; // 障碍物包围盒
struct Object_Position dino; // 恐龙包围盒
extern double pi; // 圆周率(外部定义)
extern uint8_t KeyNum; // 按键值(外部定义)
/* 图片资源声明(在其他文件中定义) */
extern const uint8_t Ground[]; // 地面图片数组
extern const uint8_t Barrier[][BARRIER_WIDTH * BARRIER_HEIGHT / 8]; // 障碍物图片
extern const uint8_t Dino[][DINO_WIDTH * DINO_HEIGHT / 8]; // 恐龙图片
extern const uint8_t Cloud[]; // 云朵图片
/* ==================== 函数声明 ==================== */
void Show_Score(void);
void Show_Ground(void);
void Show_Barrier(void);
void Show_Cloud(void);
void Show_Dino(void);
void Calc_Barrier_Pos(void);
void Calc_Dino_Pos(void);
int isColliding(struct Object_Position *a, struct Object_Position *b);
int DinoGame_Animation(void);
void Dino_Tick(void);
void DinoGame_Pos_Init(void);
/* ==================== 画面绘制函数 ==================== */
/**
* @brief 在屏幕右上角显示当前分数
*/
void Show_Score(void)
{
OLED_ShowNum(SCORE_X, SCORE_Y, Score, SCORE_DIGITS, OLED_6X8);
}
/**
* @brief 绘制循环滚动的地面
* @note 通过偏移量截取不同位置的地面图片,实现无缝滚动
*/
void Show_Ground(void)
{
if (Ground_Pos < OLED_WIDTH)
{
/* 偏移量较小,直接单段截取 */
for (uint8_t i = 0; i < OLED_WIDTH; i++)
{
OLED_DisplayBuf[GROUND_ROW][i] = Ground[i + Ground_Pos];
}
}
else
{
/* 偏移量较大,分两段拼接:后半段 + 前半段 */
uint16_t offset = GROUND_WIDTH - 1 - Ground_Pos;
for (uint8_t i = 0; i < offset; i++)
{
OLED_DisplayBuf[GROUND_ROW][i] = Ground[i + Ground_Pos];
}
for (uint8_t i = offset; i < OLED_WIDTH; i++)
{
OLED_DisplayBuf[GROUND_ROW][i] = Ground[i - offset];
}
}
}
/**
* @brief 绘制障碍物(仙人掌)
*/
void Show_Barrier(void)
{
OLED_ShowImage(barrier.min_x, barrier.min_y,
BARRIER_WIDTH, BARRIER_HEIGHT,
Barrier[Barrier_flag]);
}
/**
* @brief 绘制背景云朵
*/
void Show_Cloud(void)
{
OLED_ShowImage(OLED_WIDTH - 1 - Cloud_Pos, CLOUD_Y,
CLOUD_WIDTH, CLOUD_HEIGHT,
Cloud);
}
/**
* @brief 绘制恐龙(奔跑动画 / 跳跃姿态)
*/
void Show_Dino(void)
{
if (dino_jump_flag == 0)
{
/* 地面奔跑:两张图片交替显示,形成跑动动画 */
if (Cloud_Pos % 2 == 0)
{
OLED_ShowImage(DINO_X, DINO_GROUND_Y,
DINO_WIDTH, DINO_HEIGHT,
Dino[0]);
}
else
{
OLED_ShowImage(DINO_X, DINO_GROUND_Y,
DINO_WIDTH, DINO_HEIGHT,
Dino[1]);
}
}
else
{
/* 跳跃中:使用跳跃姿态图片,Y坐标随高度变化 */
OLED_ShowImage(DINO_X, DINO_GROUND_Y - jump_Pos,
DINO_WIDTH, DINO_HEIGHT,
Dino[2]);
}
}
/* ==================== 坐标计算函数 ==================== */
/**
* @brief 计算障碍物的包围盒坐标
* @note 坐标计算与画面绘制分离,避免碰撞检测滞后
*/
void Calc_Barrier_Pos(void)
{
/* 障碍物跑出屏幕后重置位置并随机样式 */
if (Barrier_Pos >= BARRIER_RESET_POS - 1)
{
Barrier_flag = rand() % BARRIER_TYPE_COUNT;
Barrier_Pos = 0;
}
barrier.min_x = OLED_WIDTH - 1 - Barrier_Pos;
barrier.max_x = barrier.min_x + BARRIER_WIDTH;
barrier.min_y = BARRIER_Y;
barrier.max_y = barrier.min_y + BARRIER_HEIGHT;
}
/**
* @brief 计算恐龙的包围盒坐标,并处理按键跳跃
*/
void Calc_Dino_Pos(void)
{
KeyNum = Key_GetNum();
/* 只有站在地面上才能再次起跳,防止连跳 */
if (KeyNum == 1 && dino_jump_flag == 0)
{
dino_jump_flag = 1;
jump_t = 0;
}
/* 根据跳跃状态计算高度(正弦抛物线) */
if (dino_jump_flag == 1)
{
jump_Pos = (uint8_t)(JUMP_MAX_HEIGHT * sin(pi * jump_t / JUMP_DURATION));
}
else
{
jump_Pos = 0;
}
/* 更新恐龙包围盒 */
dino.min_x = DINO_X;
dino.max_x = DINO_X + DINO_WIDTH;
dino.min_y = DINO_GROUND_Y - jump_Pos;
dino.max_y = DINO_GROUND_Y + DINO_HEIGHT - jump_Pos;
}
/* ==================== 碰撞检测 ==================== */
/**
* @brief AABB轴对齐包围盒碰撞检测
* @param a 物体A的包围盒指针
* @param b 物体B的包围盒指针
* @retval 1=发生碰撞,0=未碰撞
* @note 核心原理:两个矩形在X轴和Y轴上都有重叠,则判定为碰撞
*/
int isColliding(struct Object_Position *a, struct Object_Position *b)
{
/* X轴重叠 且 Y轴重叠 = 碰撞发生 */
if ((a->max_x > b->min_x) && (a->min_x < b->max_x) &&
(a->max_y > b->min_y) && (a->min_y < b->max_y))
{
/* 碰撞处理:显示Game Over */
OLED_Clear();
OLED_ShowString(GAME_OVER_X, GAME_OVER_Y, "Game Over", OLED_8X16);
OLED_Update();
Delay_s(1);
OLED_Clear();
OLED_Update();
return 1;
}
return 0;
}
/* ==================== 游戏主循环 ==================== */
/**
* @brief 游戏主循环
* @retval 0=游戏结束(碰撞)
* @note 每一帧流程:清屏 → 计算坐标 → 绘制画面 → 刷新 → 碰撞检测
*/
int DinoGame_Animation(void)
{
while (1)
{
OLED_Clear();
/* 先计算所有物体坐标 */
Calc_Barrier_Pos();
Calc_Dino_Pos();
/* 再统一绘制画面 */
Show_Score();
Show_Ground();
Show_Barrier();
Show_Cloud();
Show_Dino();
/* 刷新屏幕 */
OLED_Update();
/* 最后检测碰撞 */
if (isColliding(&dino, &barrier) == 1)
{
return 0;
}
}
}
/* ==================== 游戏心跳时钟 ==================== */
/**
* @brief 游戏Tick函数,统一控制所有物体的运动节奏
* @note 建议放在定时器中断中调用,保证游戏速度稳定
*/
void Dino_Tick(void)
{
static uint8_t Score_Count;
static uint8_t Ground_Count;
static uint8_t Cloud_Count;
Score_Count++;
Ground_Count++;
Cloud_Count++;
/* 分数增长 */
if (Score_Count >= SCORE_SPEED)
{
Score_Count = 0;
Score++;
}
/* 地面与障碍物滚动 */
if (Ground_Count >= GROUND_SPEED)
{
Ground_Count = 0;
Ground_Pos++;
Barrier_Pos++;
if (Ground_Pos >= GROUND_WIDTH)
{
Ground_Pos = 0;
}
if (Barrier_Pos >= BARRIER_RESET_POS)
{
Barrier_Pos = 0;
}
}
/* 云朵滚动(速度最慢,营造层次感) */
if (Cloud_Count >= CLOUD_SPEED)
{
Cloud_Count = 0;
Cloud_Pos++;
if (Cloud_Pos >= CLOUD_RESET_POS)
{
Cloud_Pos = 0;
}
}
/* 跳跃计时 */
if (dino_jump_flag == 1)
{
jump_t++;
if (jump_t >= JUMP_DURATION)
{
jump_t = 0;
dino_jump_flag = 0;
}
}
}
/* ==================== 初始化 ==================== */
/**
* @brief 游戏位置初始化,开始新一局前调用
*/
void DinoGame_Pos_Init(void)
{
Score = 0;
Ground_Pos = 0;
Barrier_Pos = 0;
Barrier_flag = 0;
Cloud_Pos = 0;
jump_Pos = 0;
jump_t = 0;
dino_jump_flag = 0;
}
九、总结
做这个项目最大的收获不是 "写出了一个游戏",而是搞懂了几个核心思想:
- 包围盒碰撞 —— 原来游戏里的碰撞不是像素对像素,而是用几何图形简化
- 游戏循环范式 —— 清屏→更新→绘制→检测,所有游戏都是这个套路
- 分层滚动 —— 不同速度的背景叠加,就有了纵深感
- 数学的妙用 —— 一个 sin 函数就能搞定跳跃物理,比写死高度自然多了
如果你也在做嵌入式课设,强烈推荐试试做个小游戏,比单纯点灯有意思多了,而且能把很多知识点串起来。有问题欢迎评论区交流~
更多推荐


所有评论(0)