1. 为什么选择纯C++手写一款FlappyBird
很多人第一次接触游戏开发,都是从"抄一个FlappyBird"开始的。但绝大多数教程走的是Unity、Godot或者Python+Pygame的路线,真正用纯C++从零手写、不依赖任何游戏引擎的完整案例其实并不多。原因很简单:纯C++做游戏,意味着窗口创建、渲染循环、输入处理、碰撞检测、资源管理、音效播放这些环节全部要自己搭,工作量比调引擎API大得多。
但反过来看,这恰恰是它最大的价值。当你用纯C++把一款FlappyBird跑通之后,你会真正理解一个游戏在底层是怎么"转"起来的——帧循环是怎么驱动的、精灵是怎么被绘制到屏幕上的、碰撞盒是怎么算的、状态机是怎么切换的。这些认知是调引擎API永远给不了你的。
这篇文章面向的读者很明确:有C++基础语法(类、指针、STL容器),但没做过图形编程,想通过一个完整小项目把"游戏是怎么跑起来的"这件事搞明白的人。整篇内容会围绕一个可编译、可运行、可扩展的纯C++ FlappyBird实现展开,从环境选型、架构设计、核心模块拆解,到实际编码中的坑和调优技巧,全部讲透。
我自己的实现路线是C++17 + SDL2 + CMake,这也是目前纯C++做2D小游戏最主流、最省心的组合。下面所有代码和思路都基于这套技术栈,但其中的架构思想换成SFML、raylib甚至Win32 GDI同样适用。
2. 技术选型:SDL2、SFML还是裸Win32
2.1 三个候选方案的横向对比
在动手之前,选哪套底层库是第一个要拍板的事。纯C++做2D游戏,绕不开的选择基本就三个:SDL2、SFML、裸Win32 API。我把它们的实际体验整理成了一张表:
| 维度 | SDL2 | SFML | 裸Win32 |
|---|---|---|---|
| 学习曲线 | 平缓,C风格API | 平缓,C++面向对象 | 陡峭,消息机制繁琐 |
| 渲染方式 | 默认软件渲染,可接OpenGL | 默认硬件加速 | GDI,性能差 |
| 音效支持 | SDL_mixer,成熟 | 内置,简单 | 需自己接 |
| 跨平台 | Windows/Mac/Linux/移动端 | 同左 | 仅Windows |
| 社区资料 | 极多,游戏开发老牌 | 多,偏教学 | 少,偏系统编程 |
| 包体大小 | 小 | 中 | 无依赖 |
我最终选SDL2,核心原因是三点:第一,它的API是纯C风格,和C++混用没有心理负担,不会像SFML那样引入一堆C++对象生命周期问题;第二,SDL2的文档和示例极其丰富,遇到问题基本一搜就有答案;第三,SDL2的扩展库生态完整,SDL_image管图片、SDL_ttf管字体、SDL_mixer管音效,各司其职,不会互相耦合。
2.2 为什么不用游戏引擎
有人会问:既然有Unity这种"拖拖拽拽就能做游戏"的工具,为什么还要用纯C++手写?我的回答是:目的不同。如果你是要快速上线一款商业游戏,那当然用引擎;但如果你是要理解游戏运行的底层机制,引擎反而会把你和真相隔开。引擎帮你封装了渲染循环、资源加载、碰撞检测,你调个API就完事了,但你不知道它背后做了什么。纯C++手写,逼着你去面对每一个细节,这个过程本身就是最大的收获。
2.3 环境搭建的具体步骤
以Windows + Visual Studio为例,SDL2的接入流程如下:
- 从SDL2官网下载Development Libraries(VC版本)
- 解压后得到include、lib、bin三个目录
- 在VS项目属性里配置:
- C/C++ → 常规 → 附加包含目录:指向
SDL2/include - 链接器 → 常规 → 附加库目录:指向
SDL2/lib/x64 - 链接器 → 输入 → 附加依赖项:填入
SDL2.lib;SDL2main.lib;SDL2_image.lib;SDL2_ttf.lib;SDL2_mixer.lib
- C/C++ → 常规 → 附加包含目录:指向
- 把
SDL2/bin/x64下的所有dll复制到exe输出目录
提示:SDL2main.lib是必须的,它负责把Windows的WinMain入口转换成标准的main函数,少了它链接会报"unresolved external symbol WinMain"。
如果用CMake管理项目,配置会更清爽:
cmake_minimum_required(VERSION 3.15) project(FlappyBird CXX) set(CMAKE_CXX_STANDARD 17) find_package(SDL2 REQUIRED) find_package(SDL2_image REQUIRED) find_package(SDL2_ttf REQUIRED) find_package(SDL2_mixer REQUIRED) add_executable(FlappyBird src/main.cpp src/Game.cpp src/Bird.cpp src/Pipe.cpp src/ResourceManager.cpp ) target_link_libraries(FlappyBird SDL2::SDL2 SDL2::SDL2main SDL2_image::SDL2_image SDL2_ttf::SDL2_ttf SDL2_mixer::SDL2_mixer )CMake的好处是跨平台,同一份配置在Linux和Mac上稍作调整就能编译,不用手动改项目属性。
3. 游戏架构:一个主循环撑起整个游戏
3.1 游戏循环的本质
所有实时游戏的核心都是一个"死循环",业内叫Game Loop。它的逻辑极其简单:
while (游戏没退出) { 处理输入(); 更新逻辑(deltaTime); 渲染画面(); }这三步每秒执行几十次(通常60次),就形成了我们看到的"动起来"的画面。FlappyBird也不例外,鸟的翅膀扇动、管道的移动、分数的跳动,全都是这个循环在背后驱动。
但这里有个关键细节:更新逻辑必须基于时间,而不是基于帧数。什么意思?如果直接写bird.y += 5,那在60帧的机器上鸟每秒下降300像素,在144帧的机器上就变成720像素,游戏难度完全不一样。正确做法是传入一个deltaTime(上一帧到这一帧的秒数),写成bird.y += speed * deltaTime,这样无论帧率多少,鸟的下降速度都是一致的。
3.2 状态机的设计
FlappyBird虽然简单,但至少有四个状态:准备中(Ready)、游戏中(Playing)、碰撞后坠落(Falling)、游戏结束(GameOver)。用状态机管理是最清晰的:
enum class GameState { Ready, Playing, Falling, GameOver };每个状态下的行为不同:Ready状态下鸟原地上下浮动,点击后进入Playing;Playing状态下鸟受重力影响,管道开始移动;碰撞后进入Falling,鸟快速下坠;落地后进入GameOver,显示分数和重开按钮。
我见过不少新手把所有逻辑塞在一个update()里,用一堆if-else判断,代码很快就乱成一团。状态机的好处是每个状态只关心自己的逻辑,切换时做一次性的初始化,代码可读性和可维护性都高得多。
3.3 类的职责划分
我的实现里主要有这么几个类:
- Game:持有窗口、渲染器、状态机,负责主循环调度
- Bird:鸟的位置、速度、重力、跳跃、碰撞盒
- Pipe:单根管道的上下两部分、位置、移动、碰撞盒
- PipeManager:管理所有管道,负责生成、回收、碰撞检测
- ResourceManager:统一加载和缓存纹理、字体、音效
- ScoreBoard:分数显示和最高分记录
这种划分的核心原则是单一职责:Bird只管鸟自己,Pipe只管管道自己,谁都不越界。碰撞检测放在PipeManager里,因为它需要同时访问鸟和管道。
4. 核心模块拆解:从窗口到碰撞
4.1 窗口与渲染器的初始化
SDL2的初始化分两步:先初始化SDL本身,再创建窗口和渲染器。
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) < 0) { SDL_Log("SDL_Init failed: %s", SDL_GetError()); return -1; } SDL_Window* window = SDL_CreateWindow( "FlappyBird", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 288, 512, SDL_WINDOW_SHOWN ); SDL_Renderer* renderer = SDL_CreateRenderer( window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC );这里有两个参数值得说:288x512是FlappyBird原版的逻辑分辨率,用这个尺寸能最大程度还原原版手感;SDL_RENDERER_PRESENTVSYNC开启垂直同步,能防止画面撕裂,同时把帧率锁在显示器刷新率上,避免CPU空转。
4.2 纹理加载与资源管理
游戏里用到的图片有:背景、地面、鸟的三帧动画、上下管道、各种UI元素。如果每次用的时候都去加载一遍,性能会很差。正确做法是启动时一次性加载,用std::unordered_map<std::string, SDL_Texture*>缓存起来:
SDL_Texture* ResourceManager::loadTexture(const std::string& path) { auto it = cache.find(path); if (it != cache.end()) return it->second; SDL_Surface* surface = IMG_Load(path.c_str()); if (!surface) { SDL_Log("IMG_Load failed: %s", IMG_GetError()); return nullptr; } SDL_Texture* texture = SDL_CreateTextureFromSurface(renderer, surface); SDL_FreeSurface(surface); cache[path] = texture; return texture; }注意:
SDL_Surface是CPU内存里的像素数据,SDL_Texture是GPU显存里的纹理。加载完Surface后一定要转成Texture并释放Surface,否则会内存泄漏。
4.3 鸟的物理:重力、跳跃与旋转
鸟的运动是FlappyBird的灵魂。核心就两个变量:垂直速度velocityY和重力加速度gravity。每帧更新:
velocityY += gravity * deltaTime; y += velocityY * deltaTime;点击时给velocityY一个负值(向上),比如velocityY = -400,鸟就往上飞。重力gravity一般取1200~1500像素/秒²,这个值需要反复调,太小鸟飘得像气球,太大鸟掉得像石头。
原版FlappyBird还有一个细节:鸟会根据垂直速度旋转。上升时头朝上,下降时头朝下。实现方式是:
double angle = std::clamp(velocityY * 0.1, -30.0, 90.0);然后用SDL_RenderCopyEx带角度渲染。这个细节看起来小,但对"手感"的影响非常大,加上之后整个游戏立刻就有原版那味了。
4.4 管道的生成与回收
管道是FlappyBird的难度来源。每根管道由上下两部分组成,中间留一个缺口。生成逻辑是:
- 每隔固定距离(比如180像素)生成一对新管道
- 缺口中心Y坐标在合理范围内随机
- 缺口高度固定(比如120像素)
int gapCenter = 100 + rand() % 200; int gapHeight = 120; topPipe.rect = {x, 0, PIPE_W, gapCenter - gapHeight/2}; bottomPipe.rect = {x, gapCenter + gapHeight/2, PIPE_W, SCREEN_H - (gapCenter + gapHeight/2)};管道向左移动,移出屏幕左侧后要回收,否则内存会一直涨。我用一个std::vector<Pipe>配合erase-remove惯用法:
pipes.erase( std::remove_if(pipes.begin(), pipes.end(), [](const Pipe& p) { return p.x + PIPE_W < 0; }), pipes.end() );4.5 碰撞检测:AABB就够了
FlappyBird的碰撞检测用**AABB(轴对齐包围盒)**完全够用,不需要像素级检测。AABB的判断逻辑就是两个矩形是否重叠:
bool checkCollision(const SDL_Rect& a, const SDL_Rect& b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; }但这里有个手感优化的关键点:鸟的碰撞盒不能等于它的贴图大小,否则玩家会觉得"明明没碰到怎么就死了"。正确做法是把碰撞盒缩小一圈,比如贴图是34x24,碰撞盒用28x20,四周各留几像素的宽容度。这个细节是无数玩家吐槽"这游戏判定有问题"的根源,也是原版FlappyBird手感好的秘密之一。
5. 那些教程不会告诉你的实操坑
5.1 帧率不稳定导致的"瞬移"
我第一版实现的时候,鸟偶尔会突然穿过管道,明明前一帧还在缺口上方,下一帧就跑到下面去了。排查了半天才发现是deltaTime过大导致的。当窗口被拖动、系统卡顿的时候,某一帧的deltaTime可能达到0.5秒甚至更多,鸟一帧就移动了几百像素,直接穿过了管道。
解决方案是给deltaTime设上限:
if (deltaTime > 0.05) deltaTime = 0.05;这样即使系统卡顿,单帧最多按50毫秒计算,鸟不会瞬移。这个技巧在所有实时游戏里都通用,是必加的一道保险。
5.2 音效延迟与声道占用
用SDL_mixer播放音效时,如果每次都用Mix_PlayChannel(-1, chunk, 0),短时间内连续播放同一个音效会出问题——要么被截断,要么延迟明显。正确做法是预分配声道:
Mix_AllocateChannels(16); int channel = Mix_PlayChannel(-1, jumpSound, 0);或者干脆给每个音效固定一个声道,避免互相抢占。另外,音效文件建议用WAV格式,加载快、延迟低;背景音乐用OGG,体积小、可循环。
5.3 内存泄漏的隐蔽来源
SDL2里最容易泄漏的是Surface和Texture。每次IMG_Load出来的Surface必须SDL_FreeSurface,每次SDL_CreateTextureFromSurface出来的Texture在程序退出前必须SDL_DestroyTexture。我建议在ResourceManager的析构函数里统一清理:
ResourceManager::~ResourceManager() { for (auto& [path, tex] : cache) { SDL_DestroyTexture(tex); } cache.clear(); }另外,TTF_Font、Mix_Chunk、Mix_Music也都要记得释放。用RAII思想封装成类,能省掉大量手动清理的麻烦。
5.4 最高分持久化
FlappyBird的乐趣很大一部分来自"刷新最高分"。最高分需要持久化到本地文件,否则每次重启就清零了。最简单的方案是写一个文本文件:
void saveHighScore(int score) { std::ofstream file("highscore.txt"); file << score; } int loadHighScore() { std::ifstream file("highscore.txt"); int score = 0; if (file.is_open()) file >> score; return score; }注意:文件路径要用相对路径或者放到用户目录下,不要写死绝对路径,否则换台机器就找不到文件了。
6. 让游戏"精美"的几个视觉技巧
6.1 视差滚动背景
原版FlappyBird的背景是分层的:远处的城市、近处的树木、最前面的地面,三层以不同速度向左移动,形成视差滚动效果。实现很简单,每层维护自己的x坐标,每帧减去不同的速度:
bgFarX -= 20 * deltaTime; bgNearX -= 50 * deltaTime; groundX -= 100 * deltaTime;当某一层移出屏幕后,把它重置到右侧,形成无缝循环。这个技巧能让2D画面产生纵深感,是"精美"感的重要来源。
6.2 鸟的帧动画
鸟的翅膀扇动是三帧循环。用一个计时器控制帧切换:
animTimer += deltaTime; if (animTimer > 0.1) { animTimer = 0; currentFrame = (currentFrame + 1) % 3; }0.1秒一帧,也就是每秒10帧的动画速度,看起来自然又不突兀。这个值可以微调,快一点显得鸟很慌张,慢一点显得悠闲。
6.3 分数跳动的缓动效果
分数增加时如果直接跳变,会显得很生硬。加一个简单的缓动:分数显示值逐渐逼近真实值:
displayScore += (realScore - displayScore) * 10 * deltaTime;这样分数会有一个"追上去"的动画,视觉上舒服很多。类似的缓动技巧可以用在按钮缩放、管道生成等很多地方,是提升"精致感"的通用手段。
6.4 屏幕震动与粒子效果
碰撞瞬间加一个轻微的屏幕震动,能极大增强打击感。实现方式是渲染时给所有元素加一个随机偏移:
if (shakeTimer > 0) { shakeTimer -= deltaTime; offsetX = (rand() % 10) - 5; offsetY = (rand() % 10) - 5; }再配合几个羽毛粒子从鸟身上飞散,整个碰撞反馈就非常到位了。这些效果代码量都不大,但对"精美"的贡献是决定性的。
7. 从能跑到好玩:手感调优的实战经验
7.1 重力与跳跃力的黄金比例
FlappyBird的手感核心在于重力和跳跃力的配比。我试过很多组参数,最后稳定在:
| 参数 | 数值 | 说明 |
|---|---|---|
| 重力 | 1400 px/s² | 太大鸟掉太快,太小飘 |
| 跳跃初速度 | -420 px/s | 约等于重力的0.3倍 |
| 最大下落速度 | 600 px/s | 防止鸟掉太快穿模 |
| 管道移动速度 | 120 px/s | 原版手感 |
这组参数下,鸟从最高点到最低点大约0.6秒,玩家有充足的反应时间,但又不至于太简单。关键是要限制最大下落速度,否则鸟掉到后期会越来越快,直接穿过管道。
7.2 管道间距的难度曲线
固定间距会让游戏很快变得无聊。更好的做法是随分数动态调整:分数越高,管道间距越小、缺口越小。但要设下限,否则会变成不可能完成的任务。
int gapHeight = std::max(100, 140 - score * 2); int pipeSpacing = std::max(140, 200 - score * 3);这样前10分很轻松,20分开始有压力,30分之后就是高手区了。难度曲线平滑上升,玩家会有"再玩一把就能突破"的冲动。
7.3 输入响应的即时性
FlappyBird的点击必须零延迟。我见过一些实现把输入处理放在渲染之后,导致点击有一帧的延迟,手感立刻就差了。正确顺序是:先处理输入,再更新逻辑,最后渲染。而且输入处理要放在循环的最开头,确保这一帧的点击立刻反映到鸟的位置上。
另外,SDL2的SDL_PollEvent会一次性取出所有事件,如果一帧内玩家点了多次,要全部处理,不能只处理一次。否则快速连点时会丢输入。
7.4 死亡后的短暂停顿
碰撞后不要立刻切到GameOver界面,而是让鸟先坠落1秒左右,再显示结算。这个短暂的"坠落过程"给了玩家情绪缓冲,也让游戏节奏更自然。实现上就是Falling状态持续一段时间后再切GameOver。
8. 打包发布与后续扩展方向
8.1 静态链接与单文件发布
默认情况下SDL2是动态链接的,发布时要带一堆dll。如果想做成单exe,需要静态链接:
- 下载SDL2的静态库版本(或者自己用源码编译)
- 项目属性里改成链接
SDL2-static.lib等静态库 - 确保运行时库也设为静态(/MT)
静态链接后exe体积会大一些(几MB),但用户双击就能玩,不用管dll,体验好很多。
8.2 用资源打包避免文件散落
游戏发布时如果图片、音效散落一地,显得很不专业。可以把所有资源打包成一个二进制文件,运行时从内存加载。SDL2提供了SDL_RWFromMem,配合自定义的资源包格式就能实现。这一步不是必须的,但能让项目看起来更完整。
8.3 后续可以扩展的方向
跑通基础版之后,这个项目还有很多可以深挖的地方:
- 加入道具系统:护盾、磁铁、慢速,增加玩法深度
- 多人在线排行榜:用简单的HTTP请求上传分数
- 关卡编辑器:让玩家自己设计管道布局
- 移植到移动端:SDL2本身支持Android和iOS,改改输入处理就能跑
- 换成OpenGL渲染:把SDL_Renderer换成OpenGL,学习底层图形管线
我个人觉得最有价值的是移植到移动端这一步。触摸输入和键盘输入的逻辑差异、屏幕适配、性能优化,这些都是纯PC开发接触不到的实战经验,做完之后对整个游戏开发的理解会上一个台阶。
最后分享一个我在这个项目里踩过的坑:不要过早优化。我一开始就想着要做对象池、要做多线程资源加载,结果代码复杂度飙升,bug一堆,游戏反而跑不起来。后来全部推倒重来,先用最朴素的vector和单线程把游戏跑通,再逐步优化,效率反而高得多。先把东西做出来,再考虑做好,这个顺序不能反。