☰
C++ EasyX 从零开发 Flappy Bird:游戏循环与碰撞检测实践
2026/10/17 19:51:47 网站建设 项目流程

想做个能拿得出手的小游戏,又不想一开始就啃大而全的图形引擎,C++配EasyX做像素鸟确实是条捷径。这个项目麻雀虽小,但游戏循环、碰撞检测、状态管理、输入响应这些做游戏的核心骨架全都有,非常适合拿来练手。我去年带一个刚学完C++语法的朋友写这个,一个下午就跑通了首版,他自己都没想到能这么快。这篇就把从拆需求到调参的完整过程、我踩过的坑,以及为什么这么写,一并交代清楚。

1. 动手前先想清楚:这个游戏到底在做什么

1.1 别急着写代码,先拆需求

像素鸟(Flappy Bird)的核心规则一句话就能说清:小鸟受重力持续下落,玩家每次按键给它一个向上的冲量,让它穿过一对对上下排列的管道。碰到管道、地面或飞出顶部就算失败,穿过一对管道得一分。听起来简单,但真要写代码,摆在面前的问题一点不少。

用C++做图形界面,绕不开“用什么画图”的选择。标准库不带图形功能,控制台只能输出字符,Windows API的GDI又太底层。我见过不少新手直接拿Win32 API写,一个窗口消息循环就能劝退大半。EasyX的优势是做了大量封装,initgraph(400, 640)一句话就能打开一个绘图窗口,画圆、画矩形、输出文字都有现成函数,学习成本低到几乎可以忽略。它本质上是把Windows底层的绘图接口包了一层C++友好封装,底层跑的还是GDI,但对于这种2D小游戏,性能完全够用。

这个项目的定位就是“简单上手”,所以目标拆解要克制,别一步到位追求什么粒子特效、物理模拟。我的方案是:固定帧率主循环、单只小鸟、三根循环复用的管道、三种游戏状态(开始、运行、结束)、圆与矩形的碰撞检测。这个范围刚好卡在“有挑战但不劝退”的位置。

1.2 整体架构:把游戏拆成四个核心模块

写任何游戏,哪怕是这个体量,也别上来就在main函数里堆代码。我习惯把像素鸟拆成四个模块:

  • 状态管理:用枚举区分当前是菜单、游戏中还是结束,不同状态执行不同的逻辑和绘制。
  • 小鸟物理:记录小鸟的Y坐标和Y轴速度,每帧执行速度 += 重力; 坐标 += 速度;,按键时把速度设为一个向上初速度。
  • 管道系统:管理管道的位置、缺口高度、移动速度和得分标记。
  • 碰撞与得分:判断小鸟是否撞到管道或超出边界,穿过管道时累加分数。

这样一分,代码思路立刻清晰。你写while主循环的时候,脑子里想的就是“更新逻辑、绘制画面、控制帧率”三件事,而不是一团乱麻。

很多人第一次写会犯一个毛病:把游戏逻辑全塞进绘制代码里,画着画着管道的坐标就顺手改了。这种写法在小项目里勉强能跑,但一旦加功能,改一处牵动全身。先把模块分清楚,后面所有操作都顺。

2. 核心机制和几个关键点

2.1 窗口初始化与双缓冲

initgraph(WIDTH, HEIGHT)是EasyX的入口,窗口尺寸我选的是400 * 640。这个比例接近手机竖屏,管道上下分布的空间足够,小鸟飞行的视觉范围也舒服。太宽会让管道间距显得疏,太窄则反应时间太短,玩起来过于刺激。

比窗口大小更重要的是双缓冲。EasyX默认是直接往窗口画,如果每帧先清屏再画,屏幕会明显闪烁,像老式电视机跳帧。解决办法是用批量绘制:

initgraph(400, 640); BeginBatchDraw(); // 开始批量绘制 // ...游戏循环... FlushBatchDraw(); // 把画好的内容一次性显示 EndBatchDraw();

BeginBatchDraw()之后,所有的绘制都发生在内存画布上,FlushBatchDraw()才把内容整体刷到屏幕上。这好比画画时先在草稿纸上画完再誊到正稿,而不是画一笔誊一笔。双缓冲是EasyX小游戏体验质变的关键,不加这段必然闪屏,加了之后画面流畅稳定。

2.2 小鸟飞起来:重力与跳跃的经典处理

物理模型用的是最简单的欧拉积分。每帧(我按50毫秒一帧,也就是每秒20帧)执行:

birdVy += G; // 速度受重力影响,逐渐增大 birdY += birdVy; // 位置随速度移动 // 玩家按键时: birdVy = JUMP_SPEED; // 给一个向上的初速度

这里有个新手最容易犯迷糊的点:为什么不是birdY -= 固定值来表现上升?因为真实下落是加速运动,速度会越来越快,手感才会像“自由落体”。如果只靠固定位移,小鸟要么匀速飘,要么按键后瞬移,完全没有那种坠落的紧张感。

帧率选择也有讲究。EasyX的Sleep(50)配合批量绘制正好20帧,对逻辑计算和碰撞检测来说够用,Sleep的精度误差也不会造成明显影响。你非要用Sleep(16)跑60帧也行,但物理参数全部要重新调整,对小项目没有实际收益。

按键响应我用的是GetAsyncKeyState(VK_SPACE),它检查物理键盘的实时状态,不会阻塞主循环。_getch()那种方式会等按键输入,按下去之前整个游戏都卡住,绝对不能用在主循环里。如果你想让鼠标也能操作,EasyX的消息函数peekmessage也可以,但键盘足够简单直接。

2.3 碰撞检测:判定条件怎么写才不会误判

碰撞检测是这个项目里最需要想清楚的部分。小鸟不能简单当点处理,否则视觉上会“穿模”——明明小鸟翅膀还没碰到管道,就已经游戏结束了。我把小鸟当成一个圆,圆心在小鸟图片中心,半径按小鸟高度的一半来取。

管道是矩形,所以核心问题是“圆和矩形怎么判断相交”。有个通用算法只需要几步:先找到矩形上离圆心最近的点,再算这个点到圆心的距离,距离小于半径就说明碰上了。

// 限制值在 [low, high] 范围内 int clamp(int value, int low, int high) { if (value < low) return low; if (value > high) return high; return value; } bool hitTest(int birdX, int birdY, int r, int pipeX, int pipeY, int pipeW, int pipeH) { // 矩形上距离圆心最近的点 int closestX = clamp(birdX, pipeX, pipeX + pipeW); int closestY = clamp(birdY, pipeY, pipeY + pipeH); int dx = birdX - closestX; int dy = birdY - closestY; return dx * dx + dy * dy <= r * r; }

这个写法简洁,误判率低,而且对新手友好。如果你把小鸟当矩形去碰撞,也行,但圆与矩形的判定更接近真实视觉观感,小鸟斜着穿过缺口时不会因为角落“误伤”。我调试时把小鸟半径从8调到12逐个试,11左右的手感最平衡——太大会觉得明明看着没撞却也死了,太小又会钻空子。

3. 实操步骤与代码实现

3.1 基础框架:初始化、主循环、退出

来看一份完整的代码骨架,我按从零开始搭积木的方式拆开说。先是引入头文件和全局常量:

#include <graphics.h> #include <conio.h> #include <iostream> using namespace std; const int WIDTH = 400; const int HEIGHT = 640; const int BIRD_X = 120; // 小鸟固定X坐标,只上下移动 const int BIRD_R = 11; // 小鸟半径 const double G = 0.35; // 重力加速度 const double JUMP = -8; // 跳跃初速度 const int PIPE_WIDTH = 64; // 管道宽度 const int PIPE_GAP = 170; // 上下管道之间的缺口高度 const int PIPE_SPEED = 3; // 管道移动速度

全局常量是这份代码的灵魂。你后面调手感,90%的时间在改这里的数字。一开始就写成硬编码的魔法数字,后面换参数得满文件找,非常痛苦。

主函数结构很简单:初始化 → 主循环 → 关闭窗口。

int main() { initgraph(WIDTH, HEIGHT); BeginBatchDraw(); GameState state = MENU; Bird bird; Pipe pipes[3] = { {0, 0}, {0, 0}, {0, 0} }; int score = 0; int frame = 0; initPipes(pipes); while (true) { // 处理输入 if (GetAsyncKeyState(VK_SPACE) & 0x8000) { if (state == MENU || state == OVER) state = PLAYING; if (state == PLAYING) bird.vy = JUMP; } // 更新逻辑 if (state == PLAYING) { updateBird(&bird); updatePipes(pipes); score += checkScore(bird, pipes, &frame); } // 绘制画面 drawGame(state, bird, pipes, score); FlushBatchDraw(); Sleep(50); // 单独处理退出 if (GetAsyncKeyState(VK_ESCAPE)) break; } closegraph(); return 0; }

注意两处细节:一是GetAsyncKeyState(VK_SPACE) & 0x8000里的0x8000是判断按键高位的标准写法,不能省;二是退出检测放在循环末尾,按ESC随时能关闭窗口,调试时不用每次强杀进程。

3.2 管道的数据组织和移动逻辑

管道数据结构我推荐用数组而不是动态vector,理由很简单:屏幕上同时只需要三根管道,循环复用完全可以,而且不会因为动态扩容产生问题。每根管道只需要记录自己的X坐标、缺口中心的Y坐标和是否已经计分:

struct Pipe { int x; // 管道左侧X int gapY; // 缺口中心Y bool scored; // 是否已得分 }; void updatePipes(Pipe pipes[]) { for (int i = 0; i < 3; i++) { pipes[i].x -= PIPE_SPEED; if (pipes[i].x + PIPE_WIDTH < 0) // 完全移出左边界 { pipes[i].x = WIDTH; // 挪到右侧起点 pipes[i].gapY = rand() % (HEIGHT - 400) + 200; // 新缺口高度 pipes[i].scored = false; } } }

这个“循环回收到右边”的思路是关键。管道从左边移出去之后,立刻出现在右边当“新管道”,不用创建新对象,也不用管内存。设计时我让三根管道的初始间距正好是(WIDTH + PIPE_WIDTH) / 3左右,这样它们到达屏幕边缘的时间错开,玩家不会看到三根管道同时卡在边界。

gapY的随机范围我特意限制在200~440之间。如果缺口太靠上或太靠下,管道之间会出现极端的死区间,玩家怎么按都过不去,体验很差。

每次随机生成gapY都用rand(),但你的项目没调用srand的话,每次启动的管道初始位置都一样。在main开头加一句srand(time(NULL)),保证每局不一样。

3.3 绘制逻辑:先画什么后画什么

绘制顺序决定了画面的层级。正确的顺序是:背景 → 管道 → 小鸟 → 得分文字。后画的覆盖先画的,所以管道的底座要能压住背景,小鸟又要浮在管道前面:

void drawGame(GameState state, Bird bird, Pipe pipes[], int score) { // 背景(简单渐变/纯色天空) setbkcolor(RGB(180, 220, 245)); cleardevice(); // 地面 setfillcolor(RGB(200, 180, 120)); solidrectangle(0, HEIGHT - 40, WIDTH, HEIGHT); // 管道 for (int i = 0; i < 3; i++) { setfillcolor(RGB(80, 170, 80)); // 上管道:从窗口顶部到缺口上沿 solidrectangle(pipes[i].x, 0, pipes[i].x + PIPE_WIDTH, pipes[i].gapY - PIPE_GAP / 2); // 下管道:从缺口下沿到地面 solidrectangle(pipes[i].x, pipes[i].gapY + PIPE_GAP / 2, pipes[i].x + PIPE_WIDTH, HEIGHT - 40); } // 小鸟(黄色圆) setfillcolor(RGB(250, 200, 60)); solidcircle(bird.x, bird.y, BIRD_R); // 得分 char scoreText[32]; sprintf_s(scoreText, "Score: %d", score); settextstyle(20, 0, "Consolas"); outtextxy(WIDTH / 2 - 40, 20, scoreText); }

小鸟我用的是纯色圆,没有加载图片资源。这不是偷懒,而是“简单上手”项目的最优解——你不需要准备美术素材,程序跑起来就有完整视觉效果。等基础功能跑通了,再想换贴图不迟,loadimage+putimage换掉solidcircle就行。

drawGame里我顺便画了地面,地面既是视觉锚点,也是碰撞边界,一举两得。

3.4 参数是怎么调出来的

这部分可能是全文最值钱的。很多人代码写对了,但玩起来不是“太飘”就是“太重”,问题全在参数上。我先把默认参数列出来,然后解释为什么这么配。

参数数值作用与手感
G重力0.35下落加速度,越大坠落越快
JUMP跳跃速度-8负值表示向上,越大跳得越高
PIPE_SPEED管道速度3移动快=难度高
PIPE_GAP缺口高度170越小越难
Sleep间隔50ms一帧约20Hz

为什么G=0.35而JUMP=-8?你在心里推演一下一帧的位移:按键瞬间小鸟速度变为-8,下一帧开始重力加速度0.35叠加,第一帧位移约-8,第二帧约-7.65,然后逐渐减小到0再反向增大。也就是说按键后小鸟向上飞约23帧(约1.1秒)到达最高点,上升高度大约80像素。这个高度配合170像素的缺口,玩家的操作窗口大约有2到3帧的冗余,刚好是“紧张但不至于绝望”的难度。

管道速度3和50ms帧率配合,管道每帧移动3像素,一秒钟移动60像素。从屏幕最右侧出现到最左侧完全消失大约需要6秒,玩家反应时间充足。你要调简单就降PIPE_SPEED到2,要难一点就升到4,我实测4的时候新手胜率会断崖式下跌。

4. 常见问题与排查技巧实录

4.1 编译报错与编码乱码

我见过最多的问题就是编译时提示找不到graphics.h。这个头文件不在C++标准库里,需要先安装EasyX库到你正在用的IDE对应目录。确认安装成功后,再检查项目是否用了正确字符集。

乱码的场景通常是这样的:代码编译通过,但窗口里的中文显示成乱码。原因多半是源文件编码和EasyX内部处理编码不一致。解决办法是在项目属性里把字符集设为“使用多字节字符集”,同时把main函数改成:

// 入口点调整,避免 main 函数签名兼容问题 #pragma comment(linker, "/subsystem:windows /entry:mainCRTStartup")

如果你的IDE对这条指令敏感,另一个干净的办法是把所有中文字符串改成英文显示:

outtextxy(20, 20, _T("Score: 0"));

用_T()宏包起来,可以同时适配宽窄字符编译环境。

4.2 画面闪烁和帧率不稳

闪烁的根源就是没开双缓冲,或者开了但又随手清了屏。检查两件事:BeginBatchDraw()是否在进入循环之前调用,以及循环内FlushBatchDraw()是否每次都在所有绘制完成后调用。注意不要在绘制函数里再调用cleardevice()后立刻画新画面而不经过FlushBatchDraw,那样等于手动破坏了批量绘制。

帧率不稳则大概率是Sleep放错了位置。Sleep要放在FlushBatchDraw()后面,逻辑和绘制都不受阻塞。Sleep(50)不是精确的50毫秒,但EasyX这种轻量级项目完全够用。电脑性能差异会导致实际帧率略微波动,但20帧下波动几毫秒对物理参数的影响微乎其微。

4.3 按键没反应或碰撞误判

按键没反应先检查窗口是不是当前焦点。用GetAsyncKeyState的话,即使窗口不是焦点也能读到按键状态,但如果你在调试时开了断点,窗口焦点被IDE抢走,读取结果会受影响。点击游戏窗口让它获得焦点后再试。

碰撞误判多半是判定圆半径偏大。我把半径从11降到9试过,明显能感觉到小鸟“擦边”通过的概率增加,甚至有玩家能故意贴管飞。半径保持在10到12之间最合适,既符合视觉,又不会过于宽松。

还有一个隐蔽的坑:管道gapY随机范围如果太小,两三次循环后缺口会连着几局都挤在同一高度区间,玩家会产生“这关是不是坏了”的困惑。把随机范围拉大,每局体验才有变化。

5. 玩法打磨和扩展思路

5.1 反馈细节:得分、音效与动画

现在的版本功能完整,但如果想让玩家一眼看到“这游戏做完了”,还差一点反馈细节。得分可以在数字变化时改一下文字颜色,我用橙色高亮当前分数,过一秒再恢复白色。跳转瞬间给小鸟加一个轻微的“挤压”效果:按键那一帧把小鸟半径缩小2像素,下一帧恢复,视觉上有一种肌肉用力的感觉。

音效可以用Beep()凑合。管道通过时Beep(600, 50),碰撞时Beep(200, 300),虽然简陋,但比没声音强得多。如果你不想引入额外音频库,这是最折腾的方案里性价比最高的。

5.2 难度递进和后续扩展

基础版做完以后,可以让管道速度随分数递增:

const int speed = PIPE_SPEED + score / 5;

每过5分别提速,手感会越来越紧。缺口高度也可以跟着缩,但每次只缩2像素,别一次砍十几像素,玩家会直接崩溃。

再往后的扩展方向,我列几个排名靠前的:分数存档到文件;小鸟换成图片素材;移动端触屏版;背景滚动(两张图交替平移即可);加个暂停键。每一项单独列出来都是一个练手项目,全做完你基本就掌握了2D游戏开发的大多数套路。

我自己做这套的时候最深的体会是:参数比代码难调十倍。代码逻辑一小时写完,调G、JUMP、PIPE_GAP之间的平衡用了一晚上。建议你别照着我的参数一抄就完事,把全局常量改几组,感受一下重力0.2和0.6的手感差异,再调一口升空,找自己舒服的节奏。这个游戏真正值钱的地方,不在于代码本身,而在于你亲自把一帧一帧的物理、碰撞、反馈捏出来的过程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询