ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于EasyX的C/C++小球动画入门:帧循环、时间步长与双缓冲实战

基于EasyX的C/C++小球动画入门:帧循环、时间步长与双缓冲实战

很多人在啃完 C 语言的循环、判断、数组之后会卡在一个挺尴尬的位置:语法全认识,但让他写点“看得见”的东西就无从下手。控制台里打印个乘法表、猜个数字,做完之后成就感也就维持五分钟。我带新人入门图形编程时,第一个题目从来不是贪吃蛇也不是俄罗斯方块,而是基于 EasyX 图形库的初级 C/C++ 小球动画。原因很实在:它足够小,小到一个下午就能跑起来;又足够全,全到把图形编程最核心的那套骨架——坐标系、绘图接口、帧循环、时间步长、边界判定、缓冲机制——全都串了一遍。这篇文章就是把这个小项目从头拆一遍,讲清楚每一行为什么这么写,参数怎么定,哪些坑我踩过。不管你刚学完 C 语言想找个练手项目,还是想把图形编程的基础打扎实,都能直接从里面抄作业。

1. 为什么拿“小球”开刀:这个项目到底在练什么

1.1 从黑框控制台到一块真正的画布

控制台程序的世界观是“字符流”:你输出什么,它就往下滚什么,输出完了程序就结束。图形程序完全是另一套逻辑——它面对的是一个持续存在的像素矩阵,程序不是在“输出内容”,而是在“维护一幅画面的状态”。这个思维切换是新手最容易崴脚的地方。

我见过不止一个同学,第一次写图形程序时,想的是“我先画一个球,然后过一会儿再画一个球在右边”。结果跑起来只有右边那个球,因为第一次画的被第二次盖住或者被清掉了。他脑子里还是“打印-结束”的线性模型,而不是“每一帧重新画一整个场景”的循环模型。小球动画的第一课,其实就是把这个循环模型刻进脑子里。

一旦理解了“每一帧我都要清空画布、重算位置、重新绘制全部元素”这个循环,图形编程的门就算推开一半了。后面你学任何引擎,Unity 的 Update、游戏框架的 render 循环、甚至前端 requestAnimationFrame,本质都是这个东西。

1.2 小球动画里藏着的五项基本功

别小看一个来回弹的球,它把五个独立的知识点捆在了一起,每一个都是后续做游戏的必需品:

知识点在小球项目里的体现后续能用到的地方
坐标系与单位换算窗口左上角为原点、y 轴向下所有 UI 布局、碰撞判定
绘图接口调用画实心圆、设置颜色、输出文字绘制精灵、角色、血条
帧循环与时间步长while 循环 + dt 计算位移任何实时交互程序
状态更新逻辑位置、速度变量的累加与取反物理模拟、AI 行为
渲染与缓冲控制清屏、批量绘制、防闪烁所有画面表现的流畅度

把这五样都吃透,你再去学 Sprite、学粒子系统,会发现只是换了 API 名字。这就是我坚持拿它当第一个项目的原因——它不是玩具,它是浓缩版的主循环。

1.3 工具对比:EasyX 适合谁、不适合谁

市面上做 C/C++ 图形入门的方案不少,我大致列一下,方便你判断自己该不该走这条路:

方案上手难度优点局限
控制台字符画极低零依赖表现力太差,做不出真动画
EasyX低接口少而直观,几十行出效果只支持 Windows 平台
Qt中高跨平台、工程化强学习曲线陡,不适合第一周练手
SDL / SFML中等跨平台、生态好需要配环境、理解更多概念
直接调系统图形接口高理解底层新手会被一堆结构体劝退

EasyX 的定位很清楚:用最小的认知成本,让你在 Windows 上看到像素动起来。它的接口设计非常“教科书”,画圆就solidcircle,画线就line,几乎没有概念负担。缺点是平台绑死、能力上限不高,但作为“第一课”,这个缺点是完全可以接受的——你不需要在第一课就学跨平台。

我的建议是:如果你 now 还没写过任何窗口程序,先用 EasyX 把小球跑起来,建立手感;等你对帧循环和坐标系有肌肉记忆了,再迁移到 SDL 或 Qt,你会觉得那些库只是换了套语法。反过来,一上手就啃大框架,很容易在配置环境阶段就放弃。

2. 环境搭建:把画布先支起来

2.1 编译器、字符集与库目录的三方匹配

EasyX 的安装本身不复杂,但它对编译器的版本比较挑。这里有几个点必须先对清楚,不然后面全是玄学报错。

第一,编译器版本。EasyX 提供的是预编译好的静态库,不同版本的编译器(尤其是 VS2010、2013 这些老版本和 2019、2022 这些新版本)对应的库文件不同。安装程序一般会自动检测你机器上的 Visual Studio 版本并选择对应的库,但如果你装了多个版本,可能装到了不常用的那个上面。

第二,字符集设置。EasyX 的接口有两套,一套基于char,一套基于wchar_t。项目属性里如果设置成“使用 Unicode 字符集”,而你的代码里写的是outtextxy(10, 10, "hello")这种窄字符串,就会报类型不匹配。最省事的做法是新手阶段直接把字符集改成“使用多字节字符集”,代码里统一用窄字符。等后面熟了再切回去也不迟。

第三,库文件目录。EasyX 安装后会把graphics.h和相关库文件放到编译器对应的目录下。如果没有自动放好,你需要手动在项目属性的“VC++ 目录”里配置“包含目录”和“库目录”,或者在“链接器-附加依赖项”里加上对应的.lib文件。

提示:装完之后不要急着写代码,先新建一个空项目,只写两行initgraph和closegraph跑一下。窗口能弹出来再往下走,能省掉后面一大半的排查时间。

2.2 五分钟建好第一个能弹出窗口的工程

我把完整流程写一遍,照着做就行:

  1. 打开 Visual Studio,新建“空项目”,语言选 C++。虽然标题写的是 C/C++,但我强烈建议直接用.cpp后缀,因为 EasyX 的头文件里有类定义和重载,纯 C 编译器过不了。
  2. 在解决方案资源管理器里右键“源文件” → 添加 → 新建项 → C++ 文件,命名随意,比如ball.cpp。
  3. 项目属性 → 配置属性 → 高级 → 字符集 → 改成“使用多字节字符集”。
  4. 写下面这段最小验证代码,编译运行:
#include <graphics.h> #include <conio.h> int main() { initgraph(800, 600); // 创建 800x600 的绘图窗口 setbkcolor(RGB(24, 26, 32)); // 设置背景色为深灰蓝 cleardevice(); // 用背景色刷满整个窗口 settextcolor(RGB(230, 230, 230)); settextstyle(20, 0, _T("Consolas")); outtextxy(20, 20, _T("canvas ready, press any key...")); _getch(); // 等一个按键,防止窗口一闪而过 closegraph(); // 关闭绘图窗口,释放资源 return 0; }

这段代码跑通的标志是:弹出一个深色窗口,左上角有一行浅色文字,按任意键窗口关闭。就这么点东西,验证了头文件引用、库链接、字符集三件事。

2.3 安装阶段的四个高频坑

坑一:graphics.h找不到。八成是安装时没装到当前用的编译器版本上,或者安装完没重启 VS。重新运行安装程序,手动指定 Visual Studio 的安装路径下的VC\Tools\MSVC\<版本号>\include目录。

坑二:链接错误LNK2019 unresolved external symbol。头文件找到了但库没链上。检查安装目录下的lib文件夹是不是和编译器版本对应,或者干脆在项目属性里手动把.lib加进附加依赖项。

坑三:窗口一闪就没了。因为main跑完进程就退出了。加_getch()或者用while循环卡住,这是新手最常见的“程序没跑成功”的误判——其实程序跑成功了,只是你看不见。

坑四:中文乱码。这个纯粹是字符集和源文件编码打架。要么统一用多字节字符集加 GBK 源文件,要么统一用 Unicode 加_T()包裹字符串。别混着来。

3. 先画一颗不动的球:坐标、颜色与基本图形

3.1 图形坐标系和数学坐标系的差别

这是新手第一个必须建立的概念。初中数学里,原点是左下角,y 轴向上;而屏幕坐标系的原点是左上角,y 轴向下。也就是说,y 值越大,位置越靠下。

这个差异带来的直接后果是:很多人在写“向上飞”的时候写成了y -= speed,结果球往上跑了一会儿就出了屏幕,他还以为是边界判定写错了。其实没错,方向感建立起来就好了。

记住一个口诀:x 管左右,y 管上下;y 越大越靠下。窗口大小是800 x 600,那么有效坐标范围就是 x 从 0 到 800,y 从 0 到 600。一个半径为 20 的球,它的圆心活动范围其实是[20, 780] x [20, 580],因为球的边缘不能出界。这个“有效活动范围”的概念,在做边界判定时必须想清楚。

3.2 颜色模型与函数原型

EasyX 用 RGB 宏表示颜色,三个分量都是 0 到 255:

COLORREF c = RGB(80, 200, 255); // 亮蓝

我个人习惯的几个配色,做演示时比较耐看:

  • 背景:RGB(24, 26, 32)深灰蓝,比纯黑柔和,长时间看不刺眼
  • 球体:RGB(80, 200, 255)亮青色,对比度高
  • 描边:RGB(240, 240, 240)近白色
  • 文字:RGB(180, 190, 200)中性灰

常用绘图函数就那么几个,记住原型就够用:

void circle(int x, int y, int r); // 空心圆,x/y 是圆心,r 是半径 void solidcircle(int x, int y, int r); // 实心圆,带填充色 void line(int x1, int y1, int x2, int y2); void rectangle(int left, int top, int right, int bottom); void cleardevice(); // 用当前背景色清空整个画布 void setfillcolor(COLORREF c); // 设置填充色,影响实心图形 void setlinecolor(COLORREF c); // 设置描边色

注意一个细节:solidcircle用的颜色来自setfillcolor设置的填充色,而circle用的是setlinecolor。这两者互不影响,第一次用容易搞混,画出来一片黑。

3.3 静态小球的第一版代码

先不急着让它动,把球画在一个固定位置,确认颜色和半径都对:

#include <graphics.h> #include <conio.h> int main() { initgraph(800, 600); setbkcolor(RGB(24, 26, 32)); cleardevice(); int cx = 400, cy = 300, r = 25; setfillcolor(RGB(80, 200, 255)); setlinecolor(RGB(240, 240, 240)); solidcircle(cx, cy, r); circle(cx, cy, r); // 在实心圆外面再描一圈,视觉上更立体 _getch(); closegraph(); return 0; }

这颗球居中、半径 25、带描边。到这里你已经掌握了“在指定位置画指定图形”的全部要素。剩下的所有工作,都是围绕“怎么让 cx 和 cy 随时间变化”展开的。

心得:调试图形程序时,我习惯先用高对比度、大半径的球体,确认坐标和方向都对,再改回正常参数。用一颗半径 5 的灰色小球在深色背景上调试,找起来太费眼睛了。

4. 让它动起来:帧循环、时间步长与双缓冲

4.1 动画的本质是一帧一帧地擦掉重画

动画在计算机里没有魔法,就是“快速播放的静态图”。每一帧要做的三件事固定不变:清空画布 → 更新位置 → 重新绘制。这个顺序不能乱,也不建议省。

很多人第一次写会省掉清屏,想“球移动一点点,直接在新位置再画一个就行了”。结果跑起来是一条长长的拖尾,像彗星一样。原因在于旧的像素没人擦掉。所以cleardevice()不是可有可无的装饰,它是帧循环的第一条命令。

帧循环骨架长这样:

while (程序还在运行) { cleardevice(); // 1. 清屏 更新位置(); // 2. 算新位置 画球(); // 3. 重绘 等待一小会儿(); // 4. 控制节奏 }

四个步骤缺一不可。少了第 4 步,循环会疯狂空转,CPU 占用直接飙到 100%,球反而看不清。

4.2 按“像素/秒”而不是“像素/帧”来思考

这是初学者和有点经验的开发者之间最明显的分水岭。新手习惯写“每帧移动 5 像素”,速度变量vx = 5。在固定帧率下看起来没问题,但只要机器卡一下、或者你改一下循环里的延时,速度立刻就变了。换个电脑跑,球快得飞起或者慢得像蜗牛。

正确的做法是把速度定义成每秒移动多少像素,然后在每一帧里乘以这一帧实际经过的秒数:

double dx = vx * dt; // vx 单位是 px/s,dt 单位是 s,乘积单位是 px

这里的dt就是时间步长,这一帧距离上一帧过去了多少秒。它的计算方法有两种:

  • 简单法:用GetTickCount()取毫秒时间戳,前后相减再除以 1000.0。精度到毫秒,对小球这种速度够用。
  • 精确法:QueryPerformanceCounter(),精度到微秒级。小球项目用不上,但可以记住这个名字。

我一般用简单法,代码短,够用。算一下数值感受一下:如果我想让球每秒横穿 800 像素的窗口一整个来回,速度设vx = 1600 px/s;60 帧每秒时,dt ≈ 0.0167s,每帧位移约 26.7 像素。如果球半径是 25,那每帧移动距离比直径还大一点——这就是后面要讲的“穿透”隐患,先埋个伏笔。

dt还有一个必须处理的问题:如果你拖动窗口或者切出去一会儿,dt可能变成好几秒,球会瞬移出屏幕。所以一定要做上限钳制:

if (dt > 0.05) dt = 0.05; // 单帧最多按 50ms 计算

这个 0.05 是经验值,相当于允许最低 20 帧每秒的补偿。超过这个量就不再信任时间戳,按固定值走,宁可拖慢也不要穿模。

4.3 双缓冲:解决闪烁的关键一招

如果你直接按上面的骨架写,很可能会看到画面不停地闪。原因是:cleardevice()把屏幕刷黑的一瞬间,屏幕被“真实地”刷新了,然后你再画球,中间这个过程被人眼捕捉到了。

解决办法是双缓冲:绘制不直接画到屏幕上,而是先画到内存里的一块“后台画布”,等这一帧全部画完了,再一次性贴到屏幕上。这样屏幕看到的永远是完整的画面。

EasyX 把这件事封装成了三个函数:

BeginBatchDraw(); // 开启批量绘制,之后所有绘制都进后台缓冲 // ... 这里写你的清屏和绘制代码 ... FlushBatchDraw(); // 把后台缓冲的内容贴到屏幕 // ... 循环继续 ... EndBatchDraw(); // 结束批量绘制,一般放在循环外

摆放位置很关键:BeginBatchDraw()放在循环外面,FlushBatchDraw()放在循环里面、每帧绘制的末尾。我第一次用时把BeginBatchDraw写进了循环里,结果越跑越卡,内存还涨,排查了半天才发现是重复初始化。

4.4 完整可运行代码与逐段注释

把前面所有东西拼起来,这就是一个能真正跑动起来的小球:

#include <graphics.h> #include <conio.h> int main() { const int W = 800, H = 600; // 窗口宽高 const int R = 25; // 球半径 initgraph(W, H); setbkcolor(RGB(24, 26, 32)); cleardevice(); // 用浮点数保存位置,避免整数累加带来的精度损失 double x = 120.0, y = 150.0; double vx = 320.0, vy = 240.0; // 单位:像素/秒 BeginBatchDraw(); DWORD last = GetTickCount(); while (!_kbhit()) // 按任意键退出 { DWORD now = GetTickCount(); double dt = (now - last) / 1000.0; last = now; if (dt > 0.05) dt = 0.05; // 钳制异常大的时间步长 // 1. 更新位置 x += vx * dt; y += vy * dt; // 2. 边界判定与反弹(下一节详细讲) if (x - R < 0) { x = R; vx = -vx; } if (x + R > W) { x = W - R; vx = -vx; } if (y - R < 0) { y = R; vy = -vy; } if (y + R > H) { y = H - R; vy = -vy; } // 3. 绘制 cleardevice(); setfillcolor(RGB(80, 200, 255)); setlinecolor(RGB(240, 240, 240)); solidcircle((int)x, (int)y, R); circle((int)x, (int)y, R); // 4. 输出调试信息 settextcolor(RGB(150, 160, 170)); TCHAR buf[128]; _stprintf_s(buf, _T("pos(%.1f, %.1f) vel(%.1f, %.1f) dt=%.4f"), x, y, vx, vy, dt); outtextxy(16, 16, buf); FlushBatchDraw(); Sleep(10); // 主动让出 CPU,控制最高帧率 } EndBatchDraw(); closegraph(); return 0; }

Sleep(10)这个值可以调。它的作用是让出 CPU 时间片,避免循环空转。设成 10 毫秒,理论上最高 100 帧每秒,实际受系统调度影响大概在 60 到 90 之间波动。因为速度是按秒算的,帧率波动不影响球的运动速度,只会影响画面细腻度。这就是时间步长的好处。

调试信息那一行是我个人的习惯,把位置、速度、dt实时打在屏幕上。排查“球怎么不动了”“球怎么穿墙了”这类问题,看一眼数值就知道卡在哪个环节,比打断点快得多。

5. 边界反弹:位置修正与穿透问题

5.1 反弹的数学表达

小球撞墙之后的物理行为很简单:垂直于墙面的速度分量反向,平行于墙面的分量不变。翻译成代码就是:

  • 撞左右墙:vx = -vx,vy不动
  • 撞上下墙:vy = -vy,vx不动

判断条件要用圆心加减半径和边界比:

if (x - R <= 0) // 左边 if (x + R >= W) // 右边 if (y - R <= 0) // 上边 if (y + R >= H) // 下边

这里有个容易忽略的点:为什么是x - R而不是x?因为球有体积,圆的左边缘到达 x 轴零点时就已经贴墙了,如果等圆心到墙才反弹,会有一半的球身嵌进墙里。做碰撞判定时永远要记住“用碰撞体的边界,不是用中心点”,这条经验在任何游戏引擎里都通用。

5.2 为什么必须同时修正位置

只写vx = -vx而不改x,会出问题。考虑这样一个瞬间:球这一帧移动过头了,圆心跑到了x = -5的位置。你判断到它越界,把vx反了向,但x还是 -5。下一帧它从 -5 开始往右走,看起来就是球“陷”进墙里,慢慢才爬出来。速度快的时候尤其明显,能明显看到球在墙边“卡”了一下。

所以正确写法是速度取反 + 位置拉回边界:

if (x - R < 0) { x = R; // 把球拉回贴着墙的位置 vx = -vx; // 速度反向 }

这个x = R就是把球“推回”到刚好贴住左墙的位置。这个技巧叫位置修正,是所有手写物理判定的必备动作。你可以用整段代码跑一下对比:加上位置修正之后,反弹会变得干脆利落;不加的话,球在边缘会有明显的粘滞感。

5.3 高速穿透与子步进

前面我算过,如果速度设得很大,一帧位移可能超过球的直径。这种情况下会出现穿透:球这一帧还在左边,下一帧圆心已经跑到墙外很远的地方,虽然你的判定还是能捕获(因为条件是< 0而不是“恰好等于 0”),球会被拉回墙内然后反弹。

但如果速度再夸张一点,一帧移动 500 像素,而窗口只有 800 宽,球可能直接从左边飞到右边墙外面,中间完全跳过判定——虽然对单墙来说还是能捕获,但如果场景里有挡板、有多面墙,就会穿模。

工程上的解决方案是子步进(sub-stepping):把一帧的大位移拆成若干小步,每一步都做一次碰撞判定。伪代码如下:

double step = 8.0; // 单次判定的最大位移 int n = (int)(max(fabs(dx), fabs(dy)) / step) + 1; double sx = dx / n, sy = dy / n; for (int i = 0; i < n; ++i) { x += sx; y += sy; // 每一步都做边界判定和位置修正 }

这个技巧在初级小球项目里其实用不上——速度设慢一点完全没问题。但我建议你至少知道有这么个东西,因为一旦你后面加了重力、加了加速度,速度会越来越快,早晚会遇到穿透。提前建立这个意识,比事后排查半天要省事得多。

6. 常见问题速查与排查思路

6.1 编译期与运行期的典型报错

下面这张表基本覆盖了我在带新人时见过的绝大多数问题:

现象大概率原因处理办法
找不到graphics.h安装路径与编译器版本不匹配重装并手动指定 include 目录
LNK2019链接错误.lib没加入依赖项项目属性手动添加库文件
字符串类型不匹配报错字符集与代码风格不一致统一改成多字节字符集
窗口一闪而过主函数跑完就退出循环或_getch()卡住
画面持续闪烁没开双缓冲加BeginBatchDraw/FlushBatchDraw
球体颜色是黑的忘了设setfillcolor绘制前设置填充色
球卡在墙边不动只反了速度没修正位置加一行x = R之类的修正
球速在不同机器上不一样用了“像素/帧”当速度单位改成“像素/秒”加dt计算
CPU 占用 100%循环里没有延时每帧Sleep(8~16)
拖动窗口后球消失dt过大导致位置跳跃钳制dt上限

6.2 画面类问题的排查顺序

画面出问题的时候,容易乱找。我一般按这个顺序查,命中率很高:

第一步,确认绘制代码本身没问题。把cleardevice()和位置更新都注释掉,只画一个固定位置的球。如果球都不出来,说明是颜色、坐标或者窗口初始化的问题,跟动画逻辑无关。

第二步,确认数值对不对。把位置和速度打到屏幕上,看它们是不是在按照预期变化。经常出现的情况是:位置数值在动,但画面不动——那多半是FlushBatchDraw()的位置写错了,或者根本没调用。

第三步,确认边界判定有没有生效。把速度调得特别慢(比如vx = 20),看球撞墙时的表现。如果球慢慢滑出屏幕也不反弹,说明判定条件里的符号写反了,或者半径没参与运算。

第四步,确认性能有没有问题。如果画面卡顿,先看 CPU 占用。占用高就是没Sleep;占用低但卡,可能是双缓冲的位置摆放不对,导致每帧都在做全屏拷贝。

心得:我排查图形问题时有个笨办法但特别管用——把球的速度降到极慢,慢到你用肉眼能一帧一帧看清楚。所有“闪烁”“抖动”“瞬移”的问题在慢速下都会原形毕露。比起盯着代码猜,这个办法效率高得多。

7. 代码组织:从“一坨 main”到可扩展结构

7.1 用结构体管理球的状态

第一版代码把所有变量都摊在main里,跑是能跑,但只要你想加第二颗球,立刻就得复制一堆变量。更好的做法是先抽象出一个“球”的数据结构:

struct Ball { double x, y; // 圆心位置 double vx, vy; // 速度分量,单位 px/s int r; // 半径 COLORREF fill; // 填充色 };

有了这个结构体,更新和绘制就能写成独立函数:

void updateBall(Ball& b, double dt, int W, int H) { b.x += b.vx * dt; b.y += b.vy * dt; if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx; } if (b.x + b.r > W) { b.x = W - b.r; b.vx = -b.vx; } if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy; } if (b.y + b.r > H) { b.y = H - b.r; b.vy = -b.vy; } } void drawBall(const Ball& b) { setfillcolor(b.fill); solidcircle((int)b.x, (int)b.y, b.r); }

改成这样之后,加十颗球只需要一个Ball balls[10]数组外加一个循环。数据结构化的好处就在这里——它把“一颗球”变成了“一类东西”,代码的复用性一下子就上来了。

7.2 拆函数:init / update / render

再往前一步,把主循环拆成三个阶段的函数,代码结构会变得非常清晰:

void initGame(Ball& b, int W, int H); void updateGame(Ball& b, double dt, int W, int H); void renderGame(const Ball& b);

主函数就退化成了这样:

int main() { initgraph(800, 600); Ball b; initGame(b, 800, 600); BeginBatchDraw(); // ... 循环中依次调用 update 和 render ... EndBatchDraw(); closegraph(); return 0; }

这种拆分方式不是我发明的,是所有游戏框架的标准结构:初始化、更新、渲染三段式。你现在用 EasyX 手写,等哪天换到 Unity 或 UE,会发现人家的生命周期函数就是这三段。早早建立这个结构感,后面迁移成本几乎为零。

7.3 下一步能接什么

小球跑起来、能反弹、代码也拆干净了,接下来能练的方向其实很多,我按难度排序:

第一档,加键盘控制。用GetAsyncKeyState检测方向键,按住时给球一个加速度。这一步会让你理解“输入采样”和“状态机”的概念。

第二档,加多球和球球碰撞。两两判断圆心距离是否小于半径之和,是就做速度交换。这里会第一次遇到性能问题——球一多,两两判定就是 O(n²),你会自然地想到空间划分的优化。

第三档,加重力和摩擦。每帧给vy加上一个固定的重力增量,落地时给速度乘一个小于 1 的阻尼系数。这就是一个简版的物理引擎雏形了。

第四档,加拖尾和拖影。EasyX 没有直接的透明混合,但你可以用IMAGE对象做离屏绘制,再用AlphaBlend叠加,做出那种运动模糊的视觉效果。这一步会逼着你去理解像素级操作。

我个人的经验是,小球这个项目不要一口气做到第四档。做到第二档,你对图形编程的理解就已经超过大部分只写过控制台程序的人了。剩下的留着,等你遇到具体需求时再回来看,效率更高。

最后分享一个我踩过的坑。早期我写这个循环的时候,习惯性地用while(1)不加退出条件,结果调试时程序崩了,窗口卡在屏幕上关不掉,只能去任务管理器结束进程。后来我统一用while (!_kbhit())作为退出条件,按任意键就能干净地退出。或者更稳妥一点,用WM_QUIT消息配合peekmessage,但那个属于进阶话题了,新手阶段_kbhit()足够用。

还有一点关于浮点数的。位置变量一定要用double存,绘制的时候再强制转int。如果你直接用int存位置,每帧的位移量被取整,低速时会出现“一帧走 0 像素、一帧走 1 像素”的抖动。用浮点存、取整绘制,运动看起来会平滑得多。这个细节很小,但视觉效果差别肉眼可见。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进