帮朋友整理了一个C++练手项目:用EasyX图形库做像素鸟。这可能是你花一个周末就能做完、但能学到最多东西的小游戏了。玩法很简单,一只鸟不断往前飞,你按空格让它跳起来,穿过一根根管道,碰到任何东西就结束。但你真把它当成一个C++入门项目去做,会发现它把游戏开发里最基础的骨架都串起来了:帧循环、状态管理、重力模拟、碰撞检测、计分重置。而且用EasyX来写,省去了配置复杂图形环境的痛苦,几乎装上就能画。
这篇文章我会从设计思路、环境搭建到核心代码全部过一遍,也会把我在实际调试中踩过的坑和调参心得写出来。如果你刚学完结构体、函数、数组,正想找一个“看得见摸得着”的C++项目,这篇应该能让你直接跟着做出来。哪怕你之前完全没写过图形程序,也没有问题。
1. 为什么用 C++ 和 EasyX 做像素鸟
1.1 像素鸟是最适合C++新手的图形练手项目之一
先给结论:如果你想找一个小项目来验证自己的C++基础,像素鸟可能是最划算的选择之一。它不涉及复杂的网络编程、多线程、数据库,也不吃机器性能,核心规则用最简单的结构体和循环就能描述清楚。
更关键的是,这个项目高度浓缩了游戏程序最常见的几块内容:处理用户输入、按帧更新对象位置、判断对象碰撞、刷新画面。你在控制台里写一百个排序算法,都体会不到“画面跟着你的代码动起来”的反馈感。但像素鸟不同,你每改一行参数,小鸟飞行的速度、跳跃的高度立刻就有变化,这种即时反馈对初学者建立信心帮助非常大。
我实际统计过,核心逻辑加起来不到两百行。你不一定需要会类、模板、指针,只要熟悉变量的作用域、结构体、数组和while循环,就能跟完全程。但是如果你已经学得比较深,也完全可以用类来重构,把它拆成Bird类、PipeManager类、Game类。同一个项目能同时照顾不同水平的读者,这个事本身就很难得。
1.2 EasyX 的学习成本低到可以忽略不计
C++能用的图形方案其实不少,但我强烈建议新手首选EasyX。原因是它几乎不引入额外复杂度。它没有复杂的构建系统,也不需要你去配置OpenGL或者SDL那种跨平台环境。安装包跑一遍,编译器对应的库和头文件就装好了。
从API风格上看,EasyX特别像C/C++标准库的绘图版本。画一个填充矩形,调用solidrectangle(x1, y1, x2, y2)就行;画一个圆,调用solidcircle(cx, cy, radius)。你要做的只是指定坐标和颜色。这对刚从语法学习阶段走出来的同学来说非常友好。
当然EasyX也有局限,它只支持Windows,跨平台能力为零,不适合拿去做正式商业项目。但我们的目标就是快速验证想法、巩固C++语法,EasyX的局限在这个场景下根本算不上问题。这也是为什么很多课程设计、课后练习都用它来做小游戏。
2. 先把游戏逻辑想清楚再动手
2.1 游戏循环就是“手翻书”
很多新手第一次接触图形程序时,容易把游戏想得很玄。其实游戏画面的本质就是一本“手翻书”:每一帧画一张静态图,快速切换就成了动画。EasyX的initgraph建好窗口之后,我们要做的其实只有四件事:处理输入、更新对象状态、判断游戏是否结束、重新绘制画面。
这个循环非常固定,几乎适用于所有2D小游戏:
while (游戏运行中) { 处理按键输入; 更新小鸟位置; 更新所有管道位置; 检查碰撞和计分; 绘制背景、管道、小鸟、分数; 控制帧率; }一旦你把这条主线想清楚了,后续所有功能都只是往这个框架里填代码。比如增加音效,那就是在对应事件处多一行调用;增加道具,就是在更新对象状态时多一组逻辑。先有骨架,再填肉,这是做游戏项目最重要的思路。
2.2 三个游戏状态:READY、RUNNING、OVER
如果一上来就只写一个死循环,游戏很容易变成一团乱麻。我建议从第一款游戏开始就引入简单的状态机。像素鸟的状态其实只有三个:等待开始、游戏中、游戏结束。
我用一个枚举来管理:
enum GameState { READY, RUNNING, OVER };READY状态下,小鸟停在屏幕左侧轻微上下浮动,提示玩家按空格开始。RUNNING状态下,重力生效、管道移动、计分和碰撞检测都正常跑。OVER状态下,小鸟停止移动,画面显示最终分数,并且检测玩家是否按了重开键。
这种状态分离的好处是,你不会在RUNNING逻辑里到处塞“是否已经结束”的特殊判断。代码读起来一目了然,后面想加暂停状态,也只需要再增加一个枚举值。
2.3 数据怎么组织:小鸟与管道
像素鸟里有两类核心对象,我用两个结构体来组织。
struct Bird { float x, y; // 位置 float vy; // y方向速度 float r; // 绘制半径 }; struct Pipe { int x; // 管道左上角x int gapCenterY; // 间隙中心y坐标 bool passed; // 是否已经计过分 };有一点需要注意,小鸟的位置和速度我都用了float。因为重力累加是按帧进行的,如果用整数,每帧不到1像素的位移会被截断成0,动画就会显得一顿一顿的。绘制到屏幕的时候再把float转成int就行,计算始终用浮点。
管道看起来是上下两根,但我只存一个gapCenterY,配合固定的管道宽度和间隙宽度,就能在绘制时推出上下两根矩形的位置。这样数据结构非常精简,也不需要维护上下两根管道的状态。
3. 初始化环境,让第一个窗口亮起来
3.1 安装EasyX与创建工程
环境配置本身不难,但也很容易踩坑。你需要准备一个支持C++开发的IDE,然后在浏览器里搜索EasyX官网,下载安装包。安装的时候EasyX会自动检测你机器上的编译器环境,选中对应的版本安装即可。注意如果你同时装了32位和64位的IDE,两个版本都要勾选,否则某个位数下的工程会提示找不到头文件。
新建工程的时候,我建议创建一个空的C++控制台项目,然后在源文件里包含graphics.h。虽然我们做的是图形窗口,但控制台项目完全能运行EasyX程序,还方便用printf输出临时调试信息。新建工程之后有个特别容易忽略的配置:项目属性里的“字符集”。如果你用的IDE默认是Unicode字符集,而代码里写了普通字符串传给outtextxy,编译器会直接报错。老实的做法是使用_T("文本")宏包裹字符串,或者把字符集改成“多字节字符集”。
3.2 画一个窗口:坐标系和颜色
环境没问题之后,先别急着写游戏逻辑,从最简单的画图开始。下面这段代码能画出一片天空和一块草地:
#include <graphics.h> #include <conio.h> int main() { initgraph(400, 640); // 窗口宽400,高640 setbkcolor(RGB(135, 206, 235)); // 天空蓝 cleardevice(); setfillcolor(RGB(34, 139, 34)); // 草地绿 solidrectangle(0, 545, 400, 640); getch(); closegraph(); return 0; }如果你之前只写过控制台程序,这里有两个容易懵的点。第一,setbkcolor只是设置背景色,并不会真的清屏,必须调用cleardevice()才能用背景色覆盖整个窗口。第二,EasyX的窗口坐标系默认原点在左上角,x轴向右,y轴向下。这跟你数学课上学的坐标系正好相反,所以“往上跳”实际上是让y坐标减小,很多新手在这里把方向搞反,导致小鸟按空格后一个劲往下掉。
3.3 从第一帧就做批量绘制
在你还没开始做动画之前,我先强烈建议你把批量绘制的框架写好。直接在一个循环里cleardevice()然后画图,屏幕上通常会出现明显的闪烁。原因是每一帧的“清屏”和“绘制”是分两步提交给屏幕的,眼睛看到中间被清空的瞬间,就会觉得画面在闪。
解决方法就是双缓冲,EasyX提供了现成的函数:
BeginBatchDraw(); // 开启批量绘制 while (true) { cleardevice(); // 这里放所有绘制代码 FlushBatchDraw(); // 把整帧一次性显示到屏幕 } EndBatchDraw();BeginBatchDraw之后,所有绘制都先在内存缓冲区里进行,直到调用FlushBatchDraw才整体上屏。这样做既能消除闪烁,也让帧画面的更新更完整。我建议第一版程序就用这个结构,省得后面为了优化闪屏问题大改绘制代码。
4. 把像素鸟的核心功能逐个落地
4.1 草地滚动与背景绘制
静态背景很容易画,问题在于怎么让背景“动”起来。像素鸟原版的草地是持续向左移动的,这样玩家才会产生小鸟一直往前飞的错觉。
最简单的实现方案是用两个矩形拼出草地,每个矩形宽度等于窗口宽度。每帧让它们的x坐标减少固定的偏移量,当其中一个矩形的右边界小于0时,把它挪到另一个矩形右侧继续排。这里推荐一个更簡洁的管理方式:只维护一个groundOffset变量,每帧让groundOffset -= speed,然后根据groundOffset对草地宽度取模,决定画哪两个矩形。
草地高度可以固定放在窗口底部。我用的窗口高度是640,草地从y=545开始,也就是草地高度95像素。这个小参数会影响后面管道的可生成范围,所以最好一开始就定成常量。
4.2 小鸟跳跃:重力、速度与手感调试
像素鸟的核心手感,其实就是两个参数:重力和跳跃初速度。
重力模拟代码非常短,每帧执行两行:
bird.vy += GRAVITY; // 重力,我用的0.45 bird.y += bird.vy; // 按速度更新位置当玩家按下空格时,直接给小鸟一个向上的初速度:
bird.vy = -JUMP_SPEED; // 我用的-7.5为什么重力要选一个看起来不整的数?因为这两个参数要配合屏幕高度和帧率一起调。重力太小,小鸟轻飘飘的,玩家会不耐烦;重力太大,反应时间太短,玩家会摔键盘。我实测下来,在400x640窗口、约60帧每秒的情况下,GRAVITY = 0.45、JUMP_SPEED = -7.5这组参数比较接近原版的手感。你可以以这个为基准,再根据自己的喜好微调。
跳跃触发有个经典问题:如果你检测“空格按下”就立刻跳,按住空格不放时小鸟会连续跳,根本没法玩。解决方案是边沿检测,也就是只响应“上一帧没按下、这一帧按下”的瞬间:
bool lastKey = false; while (true) { bool curKey = (GetAsyncKeyState(VK_SPACE) & 0x8000) != 0; if (curKey && !lastKey) { bird.vy = -JUMP_SPEED; } lastKey = curKey; }这里用到了Windows的GetAsyncKeyState函数。它的好处是可以直接读取键盘当前状态,配合我们自己记录的lastKey就能判断出“按下”这个瞬间动作,而不是持续按住的状态。EasyX本身已经包含了Windows头文件,所以直接调用GetAsyncKeyState不需要额外配置。
4.3 管道生成、移动与计分逻辑
管道是成对出现的,上下两根之间留一个可以让小鸟穿过的缝隙。我定义几个常量:
const int PIPE_WIDTH = 60; const int PIPE_GAP = 160; const int PIPE_SPEED = 2;管道每隔一定距离生成一对。为了方便,可以用一个std::vector<Pipe>保存所有还在屏幕上的管道。每一帧里,每根管道的x坐标减去PIPE_SPEED,当管道完全移出屏幕左侧(比如pipe.x + PIPE_WIDTH < 0)时,把它从数组里删掉。
生成新管道时,关键是随机选择间隙中心gapCenterY。这个值不能太靠近顶部,也不能太靠近地面,否则小鸟根本没有空间飞过去。我计算时留了安全边界:
int minCenter = 120 + PIPE_GAP / 2; int maxCenter = GROUND_Y - 30 - PIPE_GAP / 2; int gapCenterY = minCenter + rand() % (maxCenter - minCenter + 1);我把GROUND_Y设为545,PIPE_GAP为160,那么minCenter = 200,maxCenter = 435。也就是说间隙中心y在200到435之间随机生成。这样上管道顶端至少能露出一点,下管道底部也始终有草地的支撑感,不会出现“管道长到屏幕内却找不到根”的奇怪画面。
计分逻辑其实没有碰触检测那么复杂。玩家通过一根管道,本质上是小鸟的x坐标已经从管道左侧越到了右侧。为了避免每帧都加分,我给每根管道设置一个passed标记:
if (!pipe.passed && pipe.x + PIPE_WIDTH < bird.x - bird.r) { pipe.passed = true; score++; }只有当管道的右边缘已经越过了小鸟的左侧边缘,这个分数才加上。因为管道是从右向左移动的,所以一旦触发加分,之后每帧都不满足!pipe.passed的条件,也就不会重复加分。
绘制管道时,我根据gapCenterY拆成上下两个矩形:
// 上管道:从窗口顶部到间隙上方 solidrectangle(pipe.x, 0, pipe.x + PIPE_WIDTH, pipe.gapCenterY - PIPE_GAP / 2); // 下管道:从间隙下方到地面 solidrectangle(pipe.x, pipe.gapCenterY + PIPE_GAP / 2, pipe.x + PIPE_WIDTH, GROUND_Y);这里上管道的顶部我直接写0,画到窗口边缘,视觉上会比留一个白边舒服很多。
4.4 碰撞检测:判定框要比画面框更“厚道”
碰撞检测是整个游戏逻辑的高潮。最简单也最稳定的做法是矩形碰撞检测。我把小鸟近似成一个矩形,把管道拆成上下两个矩形,然后逐个判断小鸟矩形是否和管道矩形相交。
为了方便调整难度和手感,我没有直接用小鸟的圆形边界做判定,而是额外定义一个碰撞框,比小鸟看起来要小一点。这在像素鸟这种“就差一点点”的游戏里非常关键。原版像素鸟能穿过看起来和一像素一样窄的缝隙,靠的就是这个缩小碰撞框的技巧。
我的碰撞判断分两部分:
// 1. 撞出边界 if (bird.y - bird.r < 0 || bird.y + bird.r > GROUND_Y) { gameOver(); } // 2. 撞管道 for (const auto& pipe : pipes) { if (bird.x + bird.r > pipe.x && bird.x - bird.r < pipe.x + PIPE_WIDTH) { if (bird.y - bird.r < pipe.gapCenterY - PIPE_GAP / 2 || bird.y + bird.r > pipe.gapCenterY + PIPE_GAP / 2) { gameOver(); } } }这段代码里我用了缩小后的bird.r。实际绘制半径可以是18,但碰撞半径我用15,相当于给玩家留出了3像素的“容错空间”。不要小看这3像素,它能把因为帧率波动造成的误判概率降下去一大截。
顶部碰撞也别忘了。小鸟跳出窗口顶部时应该直接结束,而不是等它自己落回来。虽然现实中鸟不可能冲出顶部,但在游戏逻辑里你不写这行判断,小鸟就会在屏幕外飞很久,玩家完全不知道发生了什么。
4.5 主循环汇总:可运行骨架
把上面的逻辑拼到main里,整体结构如下:
GameState state = READY; Bird bird = { 80, 320, 0, 15 }; std::vector<Pipe> pipes; int score = 0; srand((unsigned)time(nullptr)); initgraph(400, 640); BeginBatchDraw(); while (true) { // 状态切换与更新 if (state == READY) { if (Game 空格按下) state = RUNNING; } else if (state == RUNNING) { // 物理和逻辑更新 bird.vy += GRAVITY; bird.y += bird.vy; // 管道生成与移动 if (满足生成条件) 生成新管道; for (auto& p : pipes) p.x -= PIPE_SPEED; 删除移出屏幕的管道; // 计分 for (auto& p : pipes) if (!p.passed && p.x + PIPE_WIDTH < bird.x - bird.r) { p.passed = true; score++; } // 碰撞 if (检测到碰撞) state = OVER; } else if (state == OVER) { if (按下R键) 重置所有变量; } // 统一绘制 cleardevice(); drawBackground(); for (auto& p : pipes) drawPipe(p); drawBird(); drawScore(); if (state == OVER) drawGameOverText(); FlushBatchDraw(); Sleep(16); // 约60帧每秒 } closegraph();这个骨架已经是一个能玩的游戏了。细节上,你还需要实现drawPipe、drawBird、drawBackground这些绘制函数,以及一个resetGame函数来重置小鸟位置、速度、分数和管道数组。这个resetGame在重开时很有用,不要直接在OVER分支里一行一行覆盖变量,写成一个函数会让代码整洁很多。
5. 我替你先踩过的坑:问题与排查速查
5.1 画面狂闪
最直接的原因是没做批量绘制。先检查所有绘制代码是否都在BeginBatchDraw()和FlushBatchDraw()之间。还有一个隐蔽问题:如果你在FlushBatchDraw()后面又调了Sleep(16)然后再进入下一帧的cleardevice(),其实没关系,但如果你在批量绘制期间调用了Sleep,画面可能会被中途刷新,导致闪烁。确保Sleep放在两个批量绘制块的中间,也就是这一帧完全提交之后。
5.2 按一下空格跳了好几次
这是典型的“按下状态”和“按下瞬间”没分清。你的代码如果写成if (GetAsyncKeyState(VK_SPACE)) bird.vy = -JUMP;,那按住空格时每一帧都会重置速度,小鸟就像踩着弹簧一样连环弹跳。一定要维护上一帧按键状态,用上一帧为false、这一帧为true的边沿条件触发跳跃。
5.3 碰撞判定不是太严就是太松
判定太严通常是直接用整个小鸟的圆形外接矩形参与碰撞。渲染上没问题,但玩起来体感很差。缩小碰撞半径,多留2到3像素的缓冲。判定太松往往是管道间隙计算有误差,仔细检查上下管道的矩形边界和gapCenterY的加减方向。
5.4 编译不过、中文乱码等工程问题
比较常见的有几类:找不到graphics.h,大概率是EasyX没装对版本,或者IDE位数和库不对;outtextxy报参数错误,基本是字符集问题,用_T()包裹字符串或改多字节字符集;代码中同时用了sin、rand等函数但没包含对应头文件,我建议在一开始就加上<cstdlib>、<ctime>。还有一个很烦人的问题,窗口弹出后控制台也留着,这在调试时其实是好事,发布前可以在IDE里设置隐藏控制台,但这不是必须的。
我把这些常见问题和解决方法整理成一个速查表:
| 现象 | 根本原因 | 解决方向 |
|---|---|---|
| 画面闪烁 | 没有双缓冲 | 使用BeginBatchDraw/FlushBatchDraw |
| 按住空格连环跳 | 没有边沿检测 | 记录上一帧按键状态,只响应按下瞬间 |
| 小鸟穿管或判定过严 | 碰撞框过大 | 缩小碰撞半径,保留2到3像素容错 |
找不到graphics.h | EasyX没装对版本 | 重新安装并勾选匹配的IDE位数 |
outtextxy报错 | 字符集不匹配 | 使用_T()包裹字符串或改多字节字符集 |
| 管道间隙忽上忽下没有边界 | 随机范围没限制 | 用地面高度和间隙宽度计算上下安全范围 |
6. 做完之后还能怎么升级
6.1 用贴图替换几何图形
当核心玩法稳定后,最容易提升质感的是把矩形、圆形替换成图片。EasyX提供了loadimage加载图片,然后把图片用putimage绘制到窗口。小鸟可以用三帧或者更多帧扇翅膀的动画图,管道也可以用真实的绿色管道贴图。
这里有一个坑要提前提醒:loadimage用的是相对路径,图片放在exe所在目录或者项目工作目录都可能对,但如果你直接双击运行exe,工作目录是exe所在目录;如果你在IDE里按F5运行,工作目录可能是项目目录。最好把图片放到两个位置都各放一份,或者用GetModuleFileName获取exe路径后拼出图片完整路径,从根上避免“调试能找到、发布找不到”的问题。
6.2 增加难度曲线和音效
现在的游戏难度是恒定的:管道速度、间隙宽度从头到尾都一样。加一个简单的难度曲线,游戏寿命能延长不少。比如每得5分,管道移动速度增加0.3,间隙宽度减少2。
float currentSpeed = PIPE_SPEED + (score / 5) * 0.3f; int currentGap = PIPE_GAP - (score / 5) * 2; if (currentGap < 120) currentGap = 120;注意间隙宽度要设一个下限,比如120像素,否则难度会指数级上升,玩家很快会放弃。音效可以用Beep函数实现简单的提示音,跳跃一声、加分一声、碰撞一声。真加载wav文件会让代码变复杂,初学阶段完全不需要。
最后分享一个调试技巧:给游戏加一个慢动作模式。把Sleep(16)临时改成Sleep(100),所有运动都会放慢,碰撞是否合理、管道间隙是否够用,一眼就能看出来。我每次调手感都靠这个办法,比反复思考参数推导快得多。
像素鸟这个项目做下来,最有价值的不是那几十行代码,而是你第一次完整经历了“设计状态、实现循环、调参数、修bug”的全过程。做完之后再去看贪吃蛇、打砖块、飞机大战这类小游戏,你会发现它们的骨架惊人的相似。先把这个基础打牢,后续想往哪个方向深入都有了底气。