C++与EasyX打造像素鸟:图形编程实战入门

📅 发布时间:2026/10/18 2:33:50
C++与EasyX打造像素鸟:图形编程实战入门
帮朋友整理了一个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草地从y545开始也就是草地高度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::vectorPipe保存所有还在屏幕上的管道。每一帧里每根管道的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设为545PIPE_GAP为160那么minCenter 200maxCenter 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::vectorPipe 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.hEasyX没装对版本重新安装并勾选匹配的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”的全过程。做完之后再去看贪吃蛇、打砖块、飞机大战这类小游戏你会发现它们的骨架惊人的相似。先把这个基础打牢后续想往哪个方向深入都有了底气。