纯C++手写FlappyBird:游戏循环与碰撞检测实战
1. 为什么用纯C手写一款FlappyBird是个值得做的项目1.1 这个项目的核心定位与适合人群用纯C从零实现一款FlappyBird本质上是一次对游戏循环、渲染管线、碰撞检测、资源管理这四块基础能力的集中训练。它不像用Unity或Godot那样拖拖拽拽就能跑起来你需要自己处理窗口创建、帧率控制、纹理加载、输入响应这些底层细节。但恰恰因为这样做完之后你对游戏是怎么跑起来的这件事会有完全不同的理解。这个项目适合三类人第一类是学完C语法但没做过完整项目的人需要一个小而完整的练手目标第二类是想理解游戏引擎底层原理的人FlappyBird的复杂度刚好卡在能讲清楚和有东西可讲之间第三类是准备面试游戏开发岗位的人手写一个可运行的游戏循环比背八股文有说服力得多。我选择用SFML作为图形库而不是SDL或OpenGL裸写。原因很直接SFML的API设计对C开发者更友好精灵、纹理、时钟这些类都是现成的省下来的时间可以花在游戏逻辑本身。当然如果你想挑战自己用OpenGL从顶点缓冲开始写也不是不行但那是另一个量级的项目了。1.2 最终成品应该达到什么效果一个精美的FlappyBird标准不是能跑就行。我给自己定的验收标准是这样的窗口稳定60帧运行不撕裂、不卡顿小鸟有旋转动画上升时抬头、下落时低头管道随机高度生成间距合理难度曲线平滑碰撞检测精确到像素级别不是简单的矩形重叠有地面滚动效果、背景视差滚动得分显示、最高分记录、游戏结束界面完整音效反馈拍翅、得分、撞击这些要求拆开看都不难但合在一起就是一个完整的游戏项目。下面我会把每个环节的思考过程和实现细节都摊开讲。2. 开发环境搭建与SFML配置2.1 工具链选择与安装步骤我用的环境是Windows Visual Studio 2022 SFML 2.6.1。如果你用Linux或macOS流程类似只是包管理方式不同。Windows下的配置流程从SFML官网下载对应VS版本的预编译包注意选对x64还是x86解压到一个固定路径比如D:\libs\SFML-2.6.1在VS项目属性里配置三处C/C → 常规 → 附加包含目录添加D:\libs\SFML-2.6.1\include链接器 → 常规 → 附加库目录添加D:\libs\SFML-2.6.1\lib链接器 → 输入 → 附加依赖项添加sfml-graphics-d.lib;sfml-window-d.lib;sfml-system-d.lib;sfml-audio-d.libDebug模式带-d后缀把D:\libs\SFML-2.6.1\bin下的DLL文件复制到项目输出目录或者直接把这个路径加到系统PATH里注意Debug和Release模式的库文件名不同Debug带-d后缀Release不带。混用会导致链接错误这个坑我踩过不止一次。Linux下的配置sudo apt install libsfml-dev编译时加上链接参数g main.cpp -o flappy -lsfml-graphics -lsfml-window -lsfml-system -lsfml-audio2.2 项目文件结构设计别把所有代码塞进一个main.cpp里那是给自己找麻烦。我建议的结构是这样的FlappyBird/ ├── src/ │ ├── main.cpp │ ├── Game.h / Game.cpp │ ├── Bird.h / Bird.cpp │ ├── Pipe.h / Pipe.cpp │ ├── Ground.h / Ground.cpp │ └── ResourceManager.h / ResourceManager.cpp ├── assets/ │ ├── textures/ │ ├── fonts/ │ └── sounds/ └── CMakeLists.txt每个类负责一块独立的逻辑Game类持有所有对象并驱动主循环。这种拆分方式在后期加功能时优势明显——比如你想加个夜间模式只需要改Ground和背景的渲染逻辑不用动Bird和Pipe的代码。3. 游戏主循环与帧率控制的核心实现3.1 游戏循环的三种写法与选择理由游戏循环是整个项目的骨架。我见过三种写法第一种固定时间步长const double FRAME_TIME 1.0 / 60.0; double accumulator 0.0; while (window.isOpen()) { double frameTime clock.restart().asSeconds(); accumulator frameTime; while (accumulator FRAME_TIME) { update(FRAME_TIME); accumulator - FRAME_TIME; } render(); }这种写法物理更新和渲染解耦逻辑最稳定。缺点是实现稍复杂对FlappyBird这种简单游戏有点杀鸡用牛刀。第二种可变时间步长while (window.isOpen()) { float dt clock.restart().asSeconds(); update(dt); render(); }简单直接但如果某一帧卡了比如系统调度dt突然变大小鸟可能直接穿过管道。FlappyBird的碰撞检测对这种情况很敏感。第三种固定帧率垂直同步window.setVerticalSyncEnabled(true); while (window.isOpen()) { float dt clock.restart().asSeconds(); update(dt); render(); }我最终选的是第三种方案的变体开启垂直同步同时把dt限制在一个上限内比如0.05秒防止极端情况下的穿透问题。float dt clock.restart().asSeconds(); if (dt 0.05f) dt 0.05f;实操心得垂直同步不是所有显示器都靠谱有些驱动会强制关闭。更稳妥的做法是用window.setFramerateLimit(60)它不依赖显示器刷新率行为更可预测。3.2 状态机管理游戏流程FlappyBird有三个状态准备、游戏中、游戏结束。用枚举加switch管理最清晰enum class GameState { Ready, Playing, GameOver };每个状态下的update和render逻辑不同。Ready状态下小鸟原地浮动管道不动Playing状态下一切正常运转GameOver状态下小鸟坠落管道停止移动显示结束界面。状态切换的触发条件要写清楚点击或按空格从Ready进入Playing碰撞检测触发从Playing进入GameOver在GameOver状态下点击重新开始。4. 小鸟的物理运动与动画实现4.1 重力与跳跃的数值调校小鸟的运动就两个力重力和跳跃冲量。听起来简单但数值调不好手感就是不对。我的参数是这样的const float GRAVITY 1200.0f; // 像素/秒² const float JUMP_VELOCITY -400.0f; // 像素/秒 const float MAX_FALL_SPEED 600.0f; // 像素/秒每帧更新velocityY GRAVITY * dt; if (velocityY MAX_FALL_SPEED) velocityY MAX_FALL_SPEED; birdY velocityY * dt;跳跃时velocityY JUMP_VELOCITY;为什么重力是1200因为FlappyBird的屏幕高度大约600像素小鸟从最高点到最低点的时间应该在0.5秒左右。用公式h 0.5 * g * t²反推600 0.5 * g * 0.25得到g 4800。但这是自由落体实际游戏中还有跳跃冲量的抵消所以1200是个经验值配合-400的跳跃速度手感比较接近原版。注意这些数值和你的窗口分辨率强相关。如果你用800x600的窗口上面这些值可以直接用如果用1920x1080所有数值要乘以相应的缩放系数。4.2 小鸟旋转动画的实现细节原版FlappyBird的小鸟会根据垂直速度旋转。上升时抬头负角度下落时低头正角度。实现方式是每帧根据velocityY计算目标角度float targetAngle 0.0f; if (velocityY 0) { targetAngle -25.0f; // 上升时抬头 } else { targetAngle std::min(90.0f, velocityY * 0.15f); // 下落时逐渐低头 }然后平滑插值到目标角度currentAngle (targetAngle - currentAngle) * 10.0f * dt; sprite.setRotation(currentAngle);这里用了一个简单的线性插值系数10.0f控制旋转速度。调大一点旋转更跟手调小一点更柔和。我试过5到20之间的值10左右最接近原版手感。实操心得旋转的中心点要设在小鸟的身体中心不是精灵图的左上角。用sprite.setOrigin()把原点设到纹理宽高的一半否则旋转起来会像在绕圈。4.3 拍翅动画的帧切换小鸟的拍翅动画是三帧循环翅膀向上、中间、向下。用计时器控制切换float animationTimer 0.0f; int currentFrame 0; const float FRAME_DURATION 0.1f; animationTimer dt; if (animationTimer FRAME_DURATION) { animationTimer - FRAME_DURATION; currentFrame (currentFrame 1) % 3; } sprite.setTextureRect(sf::IntRect(currentFrame * 34, 0, 34, 24));帧切换速度0.1秒是试出来的。太快了像抽搐太慢了像卡顿。0.1秒刚好能看出翅膀在扇动又不会分散注意力。5. 管道生成、移动与碰撞检测5.1 管道间距与难度的平衡设计管道是FlappyBird的核心难度来源。间距太大没挑战太小过不去。我的参数const float PIPE_GAP 150.0f; // 上下管道之间的空隙 const float PIPE_WIDTH 52.0f; const float PIPE_SPEED 200.0f; // 像素/秒 const float PIPE_SPAWN_INTERVAL 1.5f; // 秒空隙150像素小鸟高度24像素留给玩家的操作空间是126像素。这个数值下只要不是连续失误一般能过。管道速度200像素/秒配合1.5秒的生成间隔管道之间的水平距离是300像素屏幕宽度800像素的话同时能看到2到3根管道。难度曲线可以通过动态调整PIPE_GAP来实现。比如每得10分空隙缩小5像素最低到120像素。这样前期轻松后期紧张。5.2 管道对象的生命周期管理管道用对象池管理避免频繁创建销毁。我预分配了10个Pipe对象循环使用class PipePool { std::vectorPipe pipes; int nextAvailable; public: Pipe* getPipe() { if (nextAvailable pipes.size()) return nullptr; return pipes[nextAvailable]; } void recycle(Pipe* p) { // 标记为可用 } };每根管道记录自己的x坐标和空隙的y坐标。当x坐标小于负的管道宽度时回收并重新初始化到屏幕右侧。注意管道的空隙y坐标要限制在合理范围内不能太靠上或太靠下。我的做法是gapY rand() % (screenHeight - PIPE_GAP - groundHeight - 100) 50。这样保证空隙不会超出屏幕也不会贴地。5.3 像素级碰撞检测的实现矩形碰撞检测对FlappyBird来说够用但不够精确。小鸟的精灵图有透明区域矩形碰撞会导致看起来没碰到却死了的情况。我的方案是缩小碰撞矩形sf::FloatRect birdBounds birdSprite.getGlobalBounds(); birdBounds.left 4; birdBounds.top 4; birdBounds.width - 8; birdBounds.height - 8;这样碰撞矩形比视觉精灵小一圈手感更宽容。原版FlappyBird也是这么做的玩家不会觉得明明没碰到。管道碰撞同理上下管道的碰撞矩形各缩小2像素。地面碰撞直接用y坐标判断if (birdY birdHeight groundY) { // 撞地 }实操心得碰撞检测的顺序很重要。先检测管道再检测地面最后检测天花板。因为管道碰撞是最常见的死因优先处理可以避免一些边界情况下的误判。6. 背景、地面与视差滚动效果6.1 多层视差滚动的实现精美的关键之一就是视差滚动。背景分三层远山、云层、地面。每层滚动速度不同const float BG_SPEED 30.0f; const float CLOUD_SPEED 60.0f; const float GROUND_SPEED 200.0f; // 和管道同速每层的实现方式一样两张相同的纹理并排每帧向左移动当一张完全移出屏幕时把它移到另一张的右边。bgX - BG_SPEED * dt; if (bgX -bgWidth) bgX bgWidth; bgSprite1.setPosition(bgX, 0); bgSprite2.setPosition(bgX bgWidth, 0);这种双缓冲滚动方式比用shader简单得多效果也够用。6.2 地面滚动的无缝衔接地面滚动的逻辑和背景一样但速度要和管道一致否则会有地面和管道不同步的违和感。地面纹理的宽度要能被屏幕宽度整除或者至少保证两张纹理拼接后没有缝隙。我用的是512像素宽的地面纹理窗口800像素宽两张纹理拼接后是1024像素足够覆盖。滚动到512像素时重置视觉上完全无缝。7. 得分、音效与游戏状态管理7.1 得分判定与最高分记录得分判定很简单当小鸟的x坐标超过管道的x坐标且该管道还没被计分时分数加一。if (!pipe-scored birdX pipe-x PIPE_WIDTH) { pipe-scored true; score; scoreSound.play(); }最高分用文件存储每次游戏结束时比较并更新std::ifstream in(highscore.txt); int highScore 0; in highScore; if (score highScore) { highScore score; std::ofstream out(highscore.txt); out highScore; }注意文件读写要加异常处理文件不存在时默认最高分为0。另外如果游戏发布到不同平台文件路径要用相对路径或平台相关的API获取可写目录。7.2 音效的加载与播放SFML的音效播放很简单sf::SoundBuffer buffer; buffer.loadFromFile(assets/sounds/flap.wav); sf::Sound flapSound(buffer); flapSound.play();但要注意同一个音效短时间内多次播放会互相打断。比如快速点击时拍翅音效会断断续续。解决方案是用sf::SoundSource::Status检查是否正在播放或者用多个Sound对象轮换。sf::Sound flapSounds[3]; int currentFlapSound 0; // 播放时 flapSounds[currentFlapSound].play(); currentFlapSound (currentFlapSound 1) % 3;这样连续点击时三个音效对象轮流播放不会互相打断。7.3 游戏结束界面的完整流程游戏结束时要做几件事停止管道移动、小鸟坠落动画、显示结束面板、更新最高分、等待玩家输入重新开始。小鸟坠落动画碰撞后给小鸟一个向上的小冲量然后让它自由落体旋转到90度。这个细节原版有加上之后死亡反馈更自然。结束面板用半透明黑色遮罩加文字sf::RectangleShape overlay(sf::Vector2f(800, 600)); overlay.setFillColor(sf::Color(0, 0, 0, 150)); window.draw(overlay);文字用SFML的Text类加载一个像素风格的字体。得分和最高分居中显示点击重新开始的提示放在下方。8. 常见问题与排查技巧实录8.1 编译与链接问题速查表问题现象可能原因解决方法链接错误无法解析的外部符号库文件没链接或Debug/Release混用检查附加依赖项Debug带-dRelease不带运行时提示缺少DLLDLL不在输出目录或PATH中复制SFML的bin目录下DLL到exe同级目录窗口创建失败显卡驱动或SFML版本不匹配更新驱动确认SFML版本与编译器匹配纹理加载失败但无报错工作目录不对用绝对路径测试确认相对路径的基准目录8.2 运行时常见异常与解决问题一小鸟穿过管道原因dt过大导致单帧位移超过管道宽度。解决限制dt上限或者用连续碰撞检测。问题二画面撕裂原因垂直同步未开启或失效。解决用setFramerateLimit(60)替代setVerticalSyncEnabled(true)。问题三音效延迟原因音频缓冲区太大。解决在SFML中设置sf::SoundBuffer的采样率或者用更短的音效文件。问题四内存持续增长原因管道对象没有回收或者纹理重复加载。解决用对象池管理管道纹理在ResourceManager中只加载一次。实操心得调试游戏逻辑时把dt固定为1/60秒这样每次运行的行为完全一致方便定位问题。等逻辑稳定后再恢复可变dt。8.3 性能优化的几个关键点FlappyBird本身不重但有几个地方容易拖性能纹理切换每帧多次setTexture会导致GPU状态切换。把同一张纹理的精灵放在一起渲染。不必要的绘制屏幕外的管道不要绘制。加一个简单的视锥剔除。字体渲染SFML的Text每帧重新生成顶点缓冲得分文字变化不频繁的话可以缓存成纹理。我实测下来优化前后帧率从稳定的60帧变成了稳定的60帧——因为本来就没跑满。但在低端设备上这些优化能保证不掉帧。9. 资源管理与代码组织经验9.1 纹理与字体的单例管理ResourceManager用单例模式保证每种资源只加载一次class ResourceManager { public: static ResourceManager getInstance() { static ResourceManager instance; return instance; } sf::Texture getTexture(const std::string name) { if (textures.find(name) textures.end()) { textures[name].loadFromFile(assets/textures/ name .png); } return textures[name]; } private: std::mapstd::string, sf::Texture textures; };这样任何地方需要纹理直接ResourceManager::getInstance().getTexture(bird)就行不用关心加载逻辑。9.2 代码组织的几个原则一个类一个文件Bird.h/Bird.cpp、Pipe.h/Pipe.cpp不要混在一起头文件用前置声明减少编译依赖加快编译速度常量集中定义所有魔法数字放在一个Config.h里方便调参更新和渲染分离update()只改数据render()只画图不要混在一起这些原则在项目小的时候看不出优势但当你想要加一个新功能时清晰的代码结构能省下大量时间。10. 后续扩展方向与个人体会这个项目做完之后可以往几个方向扩展加个简单的粒子系统做拍翅效果加个昼夜交替的背景或者加个排行榜功能。但这些都是锦上添花核心的游戏循环和碰撞检测已经完整了。我个人在实际操作中的体会是手写游戏最大的收获不是学会了某个API而是理解了帧这个概念。每一帧里你要处理输入、更新状态、检测碰撞、渲染画面所有这些在16.7毫秒内完成。这种思维方式一旦建立再看任何游戏引擎的文档都会觉得理所当然。最后分享一个小技巧调参数的时候把关键数值做成可以运行时修改的。比如用键盘的上下箭头调整重力左右箭头调整管道间距。这样调手感比改代码重新编译快十倍。等找到满意的数值再写回代码里。