如果你正在学C++,语法书翻到最后一章,指针、类、引用都懂了不少,但下一步做什么,很多人直接卡住。做控制台程序总觉得自己写的是“作业”,不是“软件”;上手正经项目又不知从哪下手。我的建议一直很固定:写一个像素鸟。用 EasyX 图形库,在 Windows 下用 C++ 把这只鸟从零画出来,让它在管道之间飞起来。
这个选择不是随便拍的。像素鸟是我见过最适合新手的“麻雀型项目”:它简单到一两个晚上能跑起来,又恰好覆盖变量、循环、函数、结构体、容器、碰撞检测、游戏状态管理这些C++入门必碰的知识点。更重要的是,你做完的不是一份练习题,而是一个有窗口、有反馈、能发给朋友玩的游戏。这篇文章就把整个技术路线和踩坑过程写下来,包括环境配置、核心机制、完整代码思路,以及我实测中遇到的三类典型翻车。
1. 为什么第一个C++小游戏推荐写像素鸟
1.1 一个小游戏能装下多少东西
先别急着写代码,想清楚“像素鸟到底难在哪”。它其实不涉及复杂算法,也不需要设计模式,核心逻辑就三件事:小鸟受重力影响下落,点击时向上跳;管道从右侧出来向左移动;小鸟碰到管道或上下边界时游戏结束。听起来简单,但每一件事背后都对应一个C++主题。
重力需要用浮点数变量累加,这是“状态”和“更新”的概念;管道同时存在很多根,数据结构上适合用std::vector存;每帧要做输入、更新、渲染三个步骤,这就是游戏主循环的雏形;游戏分“准备中、运行中、结束”三个阶段,又逼着你做一个哪怕很粗暴的有限状态机。
初学者写文字版通讯录或计算器,学到的只是语法本身;写像素鸟,是把C++当成一门“能干活”的语言来用。在我接触过的各种入门项目中,这个项目的投入产出比是最高的。很多人做完之后,再看链表、再看类,理解程度会完全不一样。
1.2 为什么是 EasyX,而不是 Win32 或 SDL
用C++写图形界面,理论上有很多路。Win32 原生 API 能画窗口、画矩形,但代码量很大,光是处理设备上下文、窗口消息循环就能劝退新手;SDL 是跨平台库,功能完整,但工程配置麻烦,下载编译、链接动态库都要折腾一遍;EasyX 则恰恰相反,它把 Windows 绘图封装成一组接近“画板”的接口,你不需要知道设备上下文是什么,调用initgraph开窗口,circle画圆,outtextxy写文字,像用画笔一样。
更重要的是 EasyX 生态的定位就是教学。它的接口命名直观,文档全是中文,网上能搜到的示例代码非常多。对新手来说,能尽快跑起来形成正反馈,远比“用最专业的工具”重要。这个选择当然有代价:EasyX 只支持 Windows,也只适合搭配 MSVC 环境使用,但作为入门项目,平台绑定不是问题,反而少了很多跨平台移植的坑。
我见过有人一上来就推荐跨平台框架,结果初学者把一半精力花在安装和路径配置上,最后仓库都没跑起来。像素鸟这种项目,本来就该用最容易上手的方式先做出来。等你把游戏逻辑吃透了,再去迁移到其他图形库,成本其实很低。
2. 环境准备:EasyX 安装和三个常见报错
2.1 装好 EasyX 后必须重开一次 IDE
EasyX 是库,不是独立软件,安装方式是运行一个下载好的安装程序,让它自动识别你电脑上的编译环境。这里第一个坑就来了:装完之后必须关闭并重新打开 IDE,否则头文件graphics.h根本找不到。
我见过不少学员在安装后没有重启,直接新建工程写#include <graphics.h>,编译报 fatal error C1083,然后怀疑安装失败,重装两三遍也没有用。其实不是没装上,而是 IDE 启动时读取的是旧的环境变量路径。重启一次,问题自己消失。正确验证安装是否成功的方式,是新建一个控制台工程,把下面的代码粘进去,能弹出窗口就说明环境没问题:
#include <graphics.h> #include <conio.h> int main() { initgraph(800, 600); setbkcolor(RGB(135, 206, 235)); cleardevice(); outtextxy(100, 100, _T("Hello EasyX")); _getch(); closegraph(); return 0; }2.2 找不到头文件、链接报错、字符集——三大入门劝退点
环境配置阶段最容易撞上的报错基本就三个。
第一个是Cannot open include file: 'graphics.h'。原因大概率是没安装成功,或安装后没有重启 IDE。另一个隐藏原因是:部分 IDE 安装多个版本,EasyX 只装到了其中某一个版本里,当前项目用的恰好不是那一个。
第二个是链接阶段报错,比如 LNK2019 unresolved external symbol。这是因为 EasyX 依赖 GDI、OpenGL、多媒体定时器等 Windows 系统库。新版 EasyX 会自动附加依赖,但老版本或某些特殊工程配置下,需要手动补上。在项目属性里找到“链接器→输入→附加依赖项”,手动加上gdi32.lib、winmm.lib、opengl32.lib,重新编译通常就能解决。
第三个是字符集问题。outtextxy、loadimage等函数在“使用 Unicode 字符集”和“使用多字节字符集”两种配置下,接收的字符串类型不一样。新手最容易的做法是:字符串一律用_T("...")包起来,字符集默认选 Unicode。如果你用outtextxy(10, 10, "abc")在 Unicode 工程下编译,大概率要报参数类型不匹配,这个坑在写分数显示时会再次出现。
2.3 主程序一闪而过的另一个坑
环境都对了,窗口能打开了,新手紧接着会碰到“窗口闪一下就消失”的问题。这是程序执行完了,窗口被自动关闭了。
解决办法很直观:让程序在关闭前等着读取一次按键。上面验证代码里的_getch()就是等按键。但写游戏时要注意,_getch()是阻塞的,如果放在主循环里,画面会卡住不动。常见的做法是用_kbhit()先检测有没有按键按下,再去读具体按键,这样主循环不会被卡死。这个细节到第4章还会讲到。
3. 像素鸟的核心机制:重力、管道与碰撞的简化处理
3.1 屏幕坐标下的重力:一帧一帧算速度
做像素鸟,第一件事是理解 EasyX 的坐标系:原点在窗口左上角,x 轴向右增大,y 轴向下增大。所以“鸟往下掉”在数学上,等于 y 坐标不断增加。
物理模型不用真的去解运动学方程,用最简单的“每帧更新速度和位置”即可:
- 每帧,垂直速度
vy增加一个固定的重力加速度g; - 每帧,小鸟的 y 坐标加上
vy; - 玩家按下跳跃时,把
vy设成一个负值(向上),而不是直接改坐标。
这就是数值积分里的半隐式欧拉方法。真实世界里的自由落体,位置随时间呈二次曲线变化,这里用16毫秒一帧的离散累加去近似,对游戏来说完全够用。如果觉得小鸟坠得太猛,调小g;如果觉得点击一下飞太高,调小JUMP_V的绝对值。
我见过有人在这里钻牛角尖,非要实现一个真实抛物线。其实像素鸟的手感本来就是“伪物理”,只要下落加速度合适、跳跃响应迅速,玩家会觉得自然。手感优先于物理精确度。
3.2 管道为什么按距离生成,而不是按帧数生成
管道生成的直觉做法是“每多少帧生成一组”。比如每120帧生成一组管道。这在理论上说得通,但实际运行时会发现一个隐患:主循环的帧率受机器性能影响。如果程序掉到30帧,每秒生成的管道数量就比60帧少一半,游戏难度会随机波动。
更稳定的做法是“按距离生成”。定义管道瞬间速度固定为每帧2像素,让一个计数器持续累加这个移动距离,累计超过某个值(比如240像素)时生成一组新管道,然后计数器归零。这样无论帧率高还是低,每两组管道之间的屏幕间隔都一致,难度稳定。
这个思路的核心是:游戏逻辑应该建立在可度量的游戏单位上,而不是建立在不可控的时间单位上。这个设计思想以后做任何横版游戏都会用到。
3.3 缝隙中心点随机,而不是上缘随机
管道设计看起来简单,其实有讲究。每根管道由上方管和下方管组成,中间留一个缝让小鸟穿过。用数据描述时,只需要存三个值:管道的 x 坐标、缝隙中心点的 y 坐标、缝隙高度。
缝隙中心点通常在一个范围内随机,比如150 ~ 450像素(窗口高600)。如果直接让“上管长度随机”,很容易出现缝隙偏上或偏下到离谱的情况。中心点随机加固定缝隙高度,能更好控制难度。新手阶段缝隙高度建议取 170~190,太窄就基本没法玩,太宽又很快腻。
随机数初始化也很关键:程序开始时调用一次srand((unsigned)time(NULL)),不要每生成一根管道就重新初始化随机种子,不然会得到一连串几乎相同的缝隙位置。
3.4 碰撞检测:圆与矩形相交,用最近点判断
像素鸟的碰撞检测有三种流派:第一,把小鸟当矩形,和管道矩形做相交判断;第二,把小鸟当点,检测点是否越界;第三,把小鸟当圆,和管道矩形做圆与矩形相交检测。第三种最接近玩家的视觉感受,因为鸟是圆的。
圆和矩形相交的经典算法是“找矩形上离圆心最近的点”:把圆心的 x 坐标限制在矩形的左右边界之间,把圆心的 y 坐标限制在矩形的上下边界之间,得到最近点坐标;再算圆心到最近点的距离,如果这个距离小于等于圆的半径,就判定为碰撞。
float clampf(float v, float lo, float hi) { return v < lo ? lo : (v > hi ? hi : v); } bool circleRectHit(float cx, float cy, float r, float rx, float ry, float rw, float rh) { float px = clampf(cx, rx, rx + rw); float py = clampf(cy, ry, ry + rh); float dx = cx - px; float dy = cy - py; return dx * dx + dy * dy <= r * r; }这段函数对上下两根管道各调用一次即可。有初学朋友问我:“直接用圆心坐标判断不行吗?”用圆心判断的问题是只把鸟当成一个点,视觉上看着快蹭到管道了,但还没判定碰撞,游戏显得“不公正”。圆与矩形的判定更符合直觉,代码也就多了三行。
4. 完整实现:从空窗口到一个能玩的像素鸟
4.1 数据结构和全局变量先铺好
写任何小游戏,先把数据模型想好。像素鸟的程序,核心数据结构只有三个:小鸟、管道、游戏状态。用结构体加枚举简单搭建即可。
struct Bird { float x = 100.0f; float y = 300.0f; float vy = 0.0f; float radius = 20.0f; }; struct Pipe { int x; int gapCenterY; bool passed = false; }; enum GameState { STATE_MENU, STATE_PLAYING, STATE_OVER };全局变量方面,我习惯把窗口尺寸、物理常量、管道容器统一放在一个位置。新手不要一上来追求高内聚低耦合,全局变量在小项目里完全可接受,重点是让自己容易看懂、改得动。
const int WIN_WIDTH = 800; const int WIN_HEIGHT = 600; const int GROUND_HEIGHT = 80; const float GRAVITY = 0.35f; const float JUMP_VY = -7.0f; const int PIPE_WIDTH = 70; const int PIPE_GAP = 180; const int PIPE_SPACING = 220; const float PIPE_SPEED = 2.0f; Bird bird; std::vector<Pipe> pipes; GameState state = STATE_MENU; int score = 0; float distanceForPipe = 0.0f;这里PIPE_SPACING表示相邻管道的水平中心距离,它和“按距离生成”的计数器直接相关。代码里没有用固定帧数生成,而是用distanceForPipe累加PIPE_SPEED,超过PIPE_SPACING就生成新管道,对应第3章讲的稳定方案。
4.2 主循环三段式:读输入、更新逻辑、画画面
游戏主循环的结构,本质上就是“读取输入→更新游戏状态→绘制画面”。这个模式在任何游戏里都一样,以后学其他图形库也会反复看到。下面是主循环的骨架:
BeginBatchDraw(); while (true) { handleInput(); update(); draw(); Sleep(16); } EndBatchDraw();handleInput要解决一个关键问题:不能使用阻塞的_getch(),否则画面卡住。正确做法是先_kbhit()检测缓冲区,再_getch()读取。如果想追求“按下瞬间就跳”的跟手感,可以用GetAsyncKeyState(VK_SPACE)的下降沿检测,但用_getch()对新手更直观。
void handleInput() { while (_kbhit()) { int key = _getch(); if (key == ' ') { if (state == STATE_PLAYING) { bird.vy = JUMP_VY; } else if (state == STATE_MENU || state == STATE_OVER) { resetGame(); state = STATE_PLAYING; } } if (key == VK_ESCAPE) { // 退出处理 } } }Sleep(16)把帧率大约控制在62帧,配合批绘,画面会非常平滑。帧率不是越高越好,固定帧率反而能让物理参数稳定可调。
4.3 更新逻辑:物理、管道、得分与碰撞的先后顺序
更新顺序会影响结果,我先移动管道,再生成新管道,再做得分判定,最后做碰撞判定。碰撞判定放到最后,避免同一帧内出现“刚穿过管道就判定撞到已经离开的管道”的错乱。
void update() { if (state != STATE_PLAYING) return; bird.vy += GRAVITY; bird.y += bird.vy; distanceForPipe += PIPE_SPEED; if (distanceForPipe >= PIPE_SPACING) { distanceForPipe -= PIPE_SPACING; Pipe p; p.x = WIN_WIDTH; p.gapCenterY = 150 + rand() % 301; pipes.push_back(p); } for (auto &p : pipes) { p.x -= (int)PIPE_SPEED; } for (auto &p : pipes) { if (!p.passed && p.x + PIPE_WIDTH < bird.x - bird.radius) { p.passed = true; score++; } bool hitUpper = circleRectHit(bird.x, bird.y, bird.radius, p.x, 0, PIPE_WIDTH, p.gapCenterY - PIPE_GAP / 2); bool hitLower = circleRectHit(bird.x, bird.y, bird.radius, p.x, p.gapCenterY + PIPE_GAP / 2, PIPE_WIDTH, WIN_HEIGHT); if (hitUpper || hitLower) { state = STATE_OVER; } } if (bird.y - bird.radius <= 0 || bird.y + bird.radius >= WIN_HEIGHT - GROUND_HEIGHT) { state = STATE_OVER; } pipes.erase( std::remove_if(pipes.begin(), pipes.end(), [](const Pipe &p) { return p.x + PIPE_WIDTH < 0; }), pipes.end()); }得分逻辑要放在移动之后、碰撞检测之前的另一个细节是:如果小鸟擦着管道边缘穿过,但因为帧间隔恰好跳过判定,同一帧又没撞到,也不会遗漏得分。因为passed标志在进入管道区域的那一刻就置位了,之后不再重复加分。
4.4 绘制画面:先画谁、后画谁,顺序别反了
绘制顺序也有讲究:先背景,再管道,再地面,再小鸟,最后写分数。顺序错了,小鸟会被管道盖住,或者分数被背景盖住,视觉上非常奇怪。
void draw() { cleardevice(); setfillcolor(RGB(130, 200, 230)); solidrectangle(0, 0, WIN_WIDTH, WIN_HEIGHT - GROUND_HEIGHT); setfillcolor(RGB(80, 160, 90)); solidrectangle(0, WIN_HEIGHT - GROUND_HEIGHT, WIN_WIDTH, WIN_HEIGHT); setfillcolor(RGB(120, 190, 100)); for (auto &p : pipes) { solidrectangle(p.x, 0, p.x + PIPE_WIDTH, p.gapCenterY - PIPE_GAP / 2); solidrectangle(p.x, p.gapCenterY + PIPE_GAP / 2, p.x + PIPE_WIDTH, WIN_HEIGHT - GROUND_HEIGHT); } setfillcolor(RGB(255, 200, 60)); solidcircle((int)bird.x, (int)bird.y, (int)bird.radius); setfillcolor(WHITE); solidcircle((int)bird.x + 7, (int)bird.y - 7, 6); setfillcolor(BLACK); solidcircle((int)bird.x + 9, (int)bird.y - 7, 3); settextstyle(32, 0, _T("微软雅黑")); settextcolor(WHITE); TCHAR buf[64]; _stprintf_s(buf, _T("得分: %d"), score); outtextxy(20, 20, buf); }小鸟的眼睛位置要跟着小鸟主体移动,否则玩家会感觉鸟的头是固定不动的。你可以试着手动改这组坐标,体会“局部坐标系”的概念:眼睛其实是相对鸟中心偏移的。
4.5 菜单和结束界面:用简单文字引导玩家
菜单和结束界面不需要额外做UI控件,用文字就够了。菜单界面写“按空格开始”,结束界面写“按空格重新开始”,中间加上当前得分。注意不同状态下,update里的物理移动不会执行,但draw照常绘制,这样玩家能停留在结束画面看到小鸟撞管的位置。
我在handleInput里已经把“菜单和死亡按空格会重置游戏”的逻辑写进去了。resetGame函数负责把bird.y重置为 300、bird.vy置零、清空pipes、分数归零。这个函数写好后,玩家“再来一局”的整个流程就通了。
5. 实测翻车记录:闪烁、手感不对与管道乱出
5.1 画面闪成幻灯片:批量绘制的救场
第一次把主循环跑起来时,我几乎以为自己做了一个频闪灯。画面闪烁非常严重,原因是每帧cleardevice清屏再绘制,窗口在没有缓冲区的情况下不断重绘,人眼看起来就是闪烁。
排查链路其实很清晰。先看是否用了BeginBatchDraw:没有的话,直接加上。用了还闪,再看是否是每次循环里成对调用BeginBatchDraw和EndBatchDraw。正确的姿势是,在游戏开始前调用一次BeginBatchDraw,每帧绘制结束后调用FlushBatchDraw,游戏结束时才调用EndBatchDraw。FlushBatchDraw的作用是把画好的内容一次性刷新到屏幕上,闪烁问题迎刃而解。
顺带说一句,如果在draw函数内部私自加了cleardevice,批绘就白用了。清理屏幕要在draw的开始处做,不要穿插在多个绘制函数里。
5.2 小鸟点一下蹦两下:输入缓冲与按键抖动
用_getch()读空格时,我发现一个问题:快速连按空格,小鸟有时候会连续跳两次,手感像在“抖动”。这是因为键盘缓冲里积压了多个按键事件,循环一次全读出来,物理上就叠加了。
解决办法很简单:读按键时清空缓冲区,或者每次只处理一个有效按键。更彻底的做法是改用GetAsyncKeyState(VK_SPACE)的下降沿检测,只在“松开后再按下”时触发跳跃。但从教学角度看,_kbhit + _getch的写法足够用,只要在输入循环里控制好读取次数,不会出现明显问题。
另外一个手感细节:跳跃判定应该在按下瞬间,而不是松开瞬间。如果你在某处用了if (_getch() == ' '),那只是读到了按键;真正决定手感的是把这个读取放到handleInput里,并且每帧只允许一个跳跃动作生效。
5.3 管道间距忽大忽小:按帧生成和按距离生成的区别
初版代码我偷懒用了“每120帧生成一根管道”,当时在开发机上跑得很正常。后来模拟低帧率环境测试,发现管道间距明显变长,游戏难度随之变化。原因就是第3章说的:帧率不稳定,帧数计时的产物就会漂移。
改成距离生成之后,效果立竿见影。我甚至做了一个小实验:把Sleep(16)改成Sleep(5),管道间距依然保持不变,因为计数器和PIPE_SPEED挂钩,不随帧率变化。这个坑非常隐蔽,希望打算自己写一遍的人能直接避开。
5.4 一套不会太难的入门调参表
物理参数是像素鸟的灵魂。调参没有标准答案,但我可以给一套实测过的手感参数,新手可以直接抄,改了容易翻车:
| 参数 | 初版数值 | 最终推荐 | 手感说明 |
|---|---|---|---|
| GRAVITY | 0.8 | 0.35 ~ 0.45 | 太大下落生硬,太小鸟漂 |
| JUMP_VY | -10 | -7 ~ -8 | 绝对值太大,一次飞太高 |
| PIPE_SPEED | 3 | 1.8 ~ 2.5 | 太快反应不过来,太慢无聊 |
| PIPE_GAP | 150 | 170 ~ 190 | 初学阶段越宽越好 |
| PIPE_SPACING | 240 | 200 ~ 240 | 决定相邻管道的节奏 |
这三个参数之间其实是联动关系:管道速度越快,缝隙就要越大;跳跃力度越大,缝隙可以稍微小一点。我调试时习惯先把PIPE_SPEED固定,再去调跳跃力度,最后才微调缝隙高度,这样每次只动一个变量,心里不会乱。
6. 进阶玩的不是代码,是状态机
6.1 菜单、游戏、结束三个状态的切换
第4章的代码已经用了枚举状态,但没有展开讲设计思想。像素鸟的逻辑天然适合用状态机描述:菜单状态不执行物理,只响应空格;游戏状态执行完整逻辑;结束状态停住画面,只展示得分和重开提示。如果不用状态机,全部逻辑堆在一起,代码很快会乱成一锅粥。
状态机在游戏开发里是核心概念。像素鸟这个规模刚好能让你体会“状态管理”的意义:状态是数据,状态切换是行为,二者分离之后,加功能会变得非常清楚。比如你要加一个“暂停”功能,只需要在枚举里加一个STATE_PAUSED,再在handleInput里加一个P键分支,其余地方都不用大改。
6.2 给游戏加个音效,立刻有“游戏感”
画图和逻辑都跑通之后,游戏已经可以玩了,但总感觉缺了点什么。音效是成本最低、提升最明显的下一步。Windows 平台自带PlaySound接口,只需要包含<windows.h>并链接winmm.lib,就可以播放 WAV 文件。
#pragma comment(lib, "winmm.lib") #include <windows.h> // 跳跃时 PlaySound(TEXT("jump.wav"), NULL, SND_FILENAME | SND_ASYNC); // 死亡时 PlaySound(TEXT("over.wav"), NULL, SND_FILENAME | SND_ASYNC);注意SND_ASYNC表示异步播放,不会阻塞游戏循环。如果没有现成的 WAV 素材,甚至可以用自定义函数生成一段短暂的白噪声,或者用Beep函数模拟简单音效。新手不要在这个阶段纠结资源格式,先让程序有声音反馈,再考虑音质。
6.3 代码跑通之后,还能往哪走
做到这里,一个可以正常游玩、有音效、有得分的像素鸟已经完成了。如果你还想继续练手,我按难度排了三个方向:
第一,难度梯度。管道间距随着得分逐步缩小,或者管道移动速度随分数加快。实现方式很简单,把PIPE_SPEED从常量改成baseSpeed + score * 0.05f即可。
第二,数据持久化。把最高分写入本地文件,每次启动游戏时读取。这里要用到 C++ 文件流,顺带复习ifstream和ofstream。这个功能麻雀虽小,却让游戏从“一次性玩具”变成了“有目标进度”的作品。
第三,双人模式。同一窗口画两只鸟,一方按空格,一方按W键,各自独立物理逻辑。管线还是同一组,输赢看谁先撞。实现量不大,但能让朋友聚会时玩起来,属于进阶里性价比最高的扩展。
最后再分享一个我个人的体会:这类小项目的价值不在代码量,而在“亲手调出手感”这个过程。我建议拿到代码的人不要直接复制运行,哪怕先读懂、再手敲一遍,最后改掉其中任意一个参数观察变化,也比一次粘贴到底学到得多。等你把参数调到“自己玩着舒服”的程度,这只像素鸟才真正属于你。