前言:这学期嵌入式课程设计,我选了做个小游戏练练手。一开始觉得不就是个恐龙跳一跳嘛,能有多难?结果真写起来,光是碰撞检测就卡了我两天…… 踩了不少坑,这里把整个项目的思路和代码都整理出来,希望能帮到同样在做课设的小伙伴。

一、项目效果

先看基于 STM32F103C8T6 最小系统板 + 0.96 寸 OLED 屏幕实现的谷歌浏览器小恐龙跳跃游戏:

恐龙自动奔跑动画(两帧切换)

按键跳跃(正弦抛物线,手感丝滑)

随机障碍物(三种仙人掌样式)

地面无限滚动 + 云朵背景

实时计分系统

AABB 矩形碰撞检测,撞上直接 Game Over

硬件就一块最小系统板加个 OLED,接线超简单,I2C 接口四根线搞定。

二、整体架构思路

整个游戏的核心其实就三件事:

  1. 画面怎么动起来 —— 所有物体坐标每一帧都变
  2. 恐龙怎么跳起来 —— 用数学函数模拟物理跳跃
  3. 怎么判断撞上了 —— 矩形包围盒碰撞算法

我把代码分成了几个模块:

物体坐标结构体(统一碰撞检测)

画面绘制模块(地面 / 云朵 / 恐龙 / 障碍物)

游戏逻辑模块(Tick 时钟 + 跳跃 + 计分)

碰撞检测模块(AABB 算法)

就这四个主要模块

三、核心模块详解

  1. 物体坐标结构体 —— 碰撞检测的基础

一开始我想的是,碰撞嘛,对比像素点不就行了?后来发现单片机根本扛不住像素级检测,太费时间了。

于是用了包围盒的思路:给每个物体套一个矩形,只判断矩形有没有重叠。

struct Object_Position{
uint8_t min_x,min_y,max_x,max_y;	
	
	
};

就这么简单一个结构体,恐龙和障碍物各定义一个实例,碰撞检测全靠它。

小 Tips:包围盒不用完全贴死图像,稍微留一点余量,玩家体验会更好,不会 "擦边就死"。

  1. 场景滚动 —— 地面和云朵怎么 "动"

地面滚动是我觉得最巧妙的部分,原理其实超简单:

  • 准备一张比屏幕宽的地面图片(我做了 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)];


}	
	

云朵也是一模一样的逻辑,就是速度慢一点,营造层次感。

  1. 恐龙跳跃 —— 正弦函数 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,轴对齐包围盒。

说白了就是:两个边都和坐标轴平行的矩形,判断它们有没有重叠。

怎么判断碰撞?

直接想 "两个矩形什么时候重叠" 有点绕,换个思路:什么时候一定不碰撞?

两个矩形不碰撞只有四种情况:

  1. 矩形 A 在 矩形 B 左边 —— A 的右边 < B 的左边
  2. 矩形 A 在 矩形 B 右边 —— A 的左边 > B 的右边
  3. 矩形 A 在 矩形 B 上边 —— A 的下边 < B 的上边
  4. 矩形 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;
}

九、总结

做这个项目最大的收获不是 "写出了一个游戏",而是搞懂了几个核心思想:

  1. 包围盒碰撞 —— 原来游戏里的碰撞不是像素对像素,而是用几何图形简化
  2. 游戏循环范式 —— 清屏→更新→绘制→检测,所有游戏都是这个套路
  3. 分层滚动 —— 不同速度的背景叠加,就有了纵深感
  4. 数学的妙用 —— 一个 sin 函数就能搞定跳跃物理,比写死高度自然多了

如果你也在做嵌入式课设,强烈推荐试试做个小游戏,比单纯点灯有意思多了,而且能把很多知识点串起来。有问题欢迎评论区交流~

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐