C++ 横版跑酷入门:EasyX 主循环、跳跃与碰撞检测
横版跑酷这类小游戏大概是每个学 C 的人绕不开的一道坎。它不需要引擎、不需要美术资源、不需要联网一台电脑、一个编译器、几百行代码就能跑出一个能玩、能死、能重新开始的东西。我最早接触 C 游戏开发的时候就是从火柴人开始的——一个圆脑袋加四根线段在屏幕上跑啊跑前面冒出一根柱子空格跳过去撞上就重来。这个项目麻雀虽小五脏俱全游戏主循环、帧率控制、状态更新、碰撞检测、随机数、渲染管线一个都不少。它解决的问题是学了很久语法却写不出能跑的东西这个尴尬适合刚学完变量、循环、函数、结构体想找个出口把知识串起来的初学者也适合想重温一下手感的老手。下面我就把这套东西从设计思路到落地代码完整拆一遍。1. 动手前先把盘子摆好项目蓝图与环境选型1.1 为什么横版跑酷是C入门阶段最合适的练手项目很多人一上手就想做五子棋、贪吃蛇、俄罗斯方块。这几个当然也能练手但它们有个共同问题状态模型比较绕。俄罗斯方块要维护方块矩阵、旋转、消行判定五子棋要处理胜负方向搜索。而横版跑酷的状态极其干净——玩家只有位置和速度两个核心量障碍物就是一根根从左往右移动的柱子判定只有一个撞没撞上。这意味着你可以把注意力从数据结构怎么设计转移到C这门语言怎么用上面。结构体怎么定义、函数怎么传参、const 和引用什么时候加、vector 怎么管理动态对象、浮点数和整数的转换在哪一步做这些才是入门阶段真正要磨的东西。跑酷项目恰好把这些点全踩一遍而且复杂度刚好卡在能做出来但不会一次成功的区间里有挑战但不劝退。另一个好处是它的手感调优空间非常大。同一套代码重力参数从 2000 改到 3000整个游戏的体验就完全不一样。这种改一个数字就能直观感受到差异的反馈循环对建立物理直觉和参数意识特别有用比做十个控制台习题册上的练习都管用。1.2 图形库怎么挑EasyX、控制台字符画、SFML三条路C 标准库本身没有图形能力这是很多新手第一次踩的坑——写了个drawCircle()发现编译器根本不认识。所以你必须在标准库之外选一个渲染方案。我实际试过三条路各自适合不同阶段的人。第一条是纯控制台字符画。用printf往终端里刷字符火柴人是O加/|\加/ \障碍是▌。好处是零依赖任何环境都能跑坏处是刷新率上不去system(cls)会闪到你眼睛疼而且终端宽度和字体一改就全乱。适合纯粹想先跑通逻辑、还没装图形库的人。第二条是 SFML 或 SDL2 这类跨平台多媒体库。功能完整声音、纹理、窗口管理都有长期来看是正道。代价是配置麻烦得手动链接一堆库文件同时还要把若干 dll 复制到可执行文件旁边新手在这里翻车的概率很高。第三条是 EasyX。这是一个专为 C 学习者设计的图形库接口极简initgraph开窗、circle画圆、line画线跟老式 C 语言教材里的graphics.h几乎一模一样。它只支持 Windows 和 Visual Studio但对我们这个项目来说完全够用而且配置只需要点几下鼠标。我这篇文章就以 EasyX 为主线逻辑层的代码换成 SFML 也几乎不用改只有绘制部分需要替换。1.3 开发环境落地VS与EasyX的配合以及vscode方案环境这一步我不打算糊弄过去因为这是新手掉队率最高的地方。我的建议是直接用 Visual Studio 2022 社区版安装的时候记得勾选使用 C 的桌面开发这个工作负载它会自动带上 MSVC 编译器和 Windows SDK。装完 VS 之后去 EasyX 官网下载安装包双击运行它会自动扫描你机器上装过的 VS 版本。你只要选中对应的版本比如 Visual Studio 2022点安装它会往 VS 的头文件目录和库目录里各放一份文件。之后新建一个空项目写代码直接编译就能过不需要手动配任何路径。如果你习惯用 vscode也可以。用 vscode 配置 c/c 环境的时候需要在tasks.json的编译参数里加上-lgraphics之类的链接选项并且要确保用的是 MSVC 工具链而不是 MinGW因为 EasyX 的库文件是 MSVC 格式的MinGW 链不上。这一层如果用 vcpkg 装步骤会更绕一点。我个人的结论是专门为了这个项目VS 的体验要好过 vscode省下来的时间够你多调十遍跳跃手感了。1.4 目录结构与主循环骨架长什么样在动手写第一行代码之前先把骨架定下来。我习惯用一个头文件加一个 cpp或者干脆全写在一个 cpp 里毕竟这个项目总共也就三百来行。核心结构分四块常量定义、数据结构、更新逻辑、渲染逻辑。主循环的形状是这样的先算这一帧过了多少时间然后把这段时间按固定步长切片一片一片地跑物理更新最后统一渲染一次画面。这个固定步长更新 同步渲染的写法是几乎所有 2D 游戏的通用结构比直接拿帧间隔塞进物理公式要稳得多。ULONGLONG last GetTickCount64(); double acc 0.0; const double FIXED_DT 1.0 / 60.0; while (running) { ULONGLONG now GetTickCount64(); double frame (now - last) / 1000.0; last now; if (frame 0.25) frame 0.25; // 切窗口回来时的防抖保护 acc frame; while (acc FIXED_DT) { Update(FIXED_DT); // 逻辑更新永远走 1/60 秒 acc - FIXED_DT; } Render(); // 渲染次数跟着实际帧率走 }固定步长的意义在于确定性。如果直接把每帧的真实间隔喂给物理公式机器卡一下间隔变成 0.1 秒玩家的位移就会被拉长到原来的六倍很容易直接穿过障碍物飞到天上。切成 1/60 秒的固定片之后无论机器多快多慢物理世界的时间流速都是一致的只有画面刷新频率在变。2. 拆开每一个零件核心机制背后的推导过程2.1 火柴人到底怎么画用几根线段撑起一个人火柴人听起来简单但真画起来还是有讲究的。我用九个元素组成一个火柴人一个圆当脑袋一条竖线当躯干两条线段当手臂两条线段当腿加起来五笔。关键在于坐标的计算方式。我习惯用左上角坐标 宽高来定位角色这跟 EasyX 里矩形的表达方式一致碰撞判定时不用再换算。假设玩家矩形是(x, y, 30, 70)那么身体的水平中线就是x 15头顶就是y脚底就是y 70。脑袋的半径取 9圆心就在(中线, y 9)脖子在y 18胯部在y 70 * 0.62大约y 43。这几个比例不是随便定的你可以理解成人体比例的一个粗略简化头占七分之一躯干占二分之一剩下的给腿。跑步动画靠的是相位变量。我维护一个animPhase每帧加上dt * 14超过 2π 就减回去。手臂和腿的水平偏移量取sin(animPhase) * 16两条腿一正一负看起来就是交替迈步。手臂反过来摆跟腿形成对侧协调这是人走路时的自然姿态加上之后画面一下子就活了。跳跃的时候把摆幅固定成一个小的常数看起来就像收腿腾空的姿势。这里有个细节坑sin返回的是浮点数直接赋给int会截断。我一般写成cx (int)swing注意别写成(int)(cx swing)之外的其他形式否则两次截断方向不一致容易出现一像素抖动。2.2 跳跃手感的来源重力与初速度的参数计算跳跃手感是整个游戏的灵魂。很多人随便填个vy -10就完事了结果跳起来像僵尸。要调好得先把两个参数算清楚起跳初速度JUMP_SPEED和重力加速度GRAVITY。竖直上抛运动里最大高度h v² / (2g)滞空总时间t 2v / g。这两个公式就是你的调参方向盘。我一开始把GRAVITY设成 1500JUMP_SPEED设成 600算出来高度是 120 像素滞空 0.8 秒。实际跑起来感觉整个人像在水里飘落地太慢节奏拖沓。后来改成GRAVITY 2600、JUMP_SPEED 850算一下高度 850² / (2 × 2600) ≈ 138.9 像素滞空 1700 / 2600 ≈ 0.654 秒。这个数字就舒服多了——跳得够高能越过大部分障碍落地又快不会让人觉得迟钝。注意重力加速度用的是像素/秒²这个单位不是现实里的 9.8。因为游戏里的空间尺度跟现实完全不是一回事你要的是看起来对不是物理上准。一般来说重力大的游戏手感偏硬朗精准重力小的偏飘逸随性没有绝对的好坏取决于你想要的调性。再补一个进阶技巧可变跳跃高度。按住空格跳得高轻点一下跳得矮。实现方式是检测按键是否松开一旦松开且当前还在上升vy 0就把上升速度截断到一个较小的值。const float JUMP_CUT 320.0f; if (!jumpHeld p.vy -JUMP_CUT) { p.vy -JUMP_CUT; }我要强调的是这里千万不能写成p.vy * 0.5f然后每帧乘一次。因为固定步长下每帧都会执行一秒钟乘 60 次速度瞬间归零跳跃会变成原地抽搐。截断到固定值才是正确做法。2.3 障碍物生成节奏间距下限是怎么算出来的障碍物间距不能瞎随机。如果两个柱子挨得太近玩家第一个还没落地第二个就到了游戏就变成了不讲理的送死。所以间距的下限必须由跳跃能力反推出来。推导逻辑是这样玩家从起跳到落地的滞空时间是t 2v / g ≈ 0.654秒。在这段时间里玩家水平方向不动跑酷游戏里角色位置固定是世界在向左移动障碍物以speed的速度向左推进。那么在滞空期间障碍物相对移动的距离就是t × speed。当speed 320像素/秒时这段距离是0.654 × 320 ≈ 209像素。也就是说玩家在空中的这段时间脚下的地面流过去了 209 像素。为了让玩家能够安全落地并准备下一次起跳两个障碍之间的水平间距至少不能小于这个值否则第二个障碍会在玩家还在空中时就追上来。我的做法是在这个基础上再加一点余量取209 60 269作为下限上限设成下限加 260。这样既有节奏变化又保证永远可通过。float NextGap(float speed) { float airTime 2.0f * JUMP_SPEED / GRAVITY; float travel airTime * speed; int low (int)(travel 60.0f); int high low 260; return (float)std::uniform_int_distributionint(low, high)(g_rng); }随着难度提升speed会越来越大这个下限也会自动跟着往上走——这是个很优雅的自适应机制。速度升到 640 的时候下限变成0.654 × 640 60 ≈ 478像素障碍反而变稀疏了但因为移动快留给你的反应时间其实更短了。2.4 碰撞判定为什么矩形碰撞够用火柴人看起来是个不规则形状但碰撞判定没必要跟着形状走。用轴对齐包围盒AABB就够了也就是把玩家和障碍各当成一个矩形检查两个矩形有没有重叠。bool AABB(const Player p, const Obstacle o) { return p.x o.x o.w p.x p.w o.x p.y o.y o.h p.y p.h o.y; }这四个条件必须同时成立才算碰撞任何一个不成立就说明两个矩形在某个方向上分开了。这种写法的好处是没有除法、没有开方纯比较运算一帧判定几十次都毫无压力。那为什么不用像素级精确碰撞因为玩家的心理预期和实际判定之间是有偏差的。如果你按火柴人的线段去做精确判定玩家会经常产生我明明躲过去了的错觉——因为视觉上火柴人的手臂伸出去很远但那不该算碰撞体积。用矩形包围盒判定范围比视觉形状略小一圈玩家的感受反而是这游戏判定很宽松很爽。这是游戏设计里一个反直觉但非常重要的点判定要偏向玩家。至于障碍物我建议再比视觉宽度缩两三个像素。视觉上柱子有一点点擦过火柴人的头发判定上不算撞这种险胜的瞬间正是跑酷游戏最上瘾的地方。3. 一行行敲出来跑酷主循环的完整实现3.1 场景绘制与双缓冲防闪烁EasyX 默认使用单缓冲绘图直接画到屏幕上。结果就是你每帧先cleardevice()清屏屏幕上会出现一瞬间的空白然后才画上新内容。60 帧每秒地闪眼睛很快就受不了了。解决办法是启用批处理绘制也就是常说的双缓冲所有绘图命令先写进内存里的一块后备画布等你调用FlushBatchDraw()的时候一整个画面原子性地贴到屏幕上。initgraph(WIDTH, HEIGHT); BeginBatchDraw(); while (running) { cleardevice(); DrawSky(); DrawGround(); DrawObstacles(); DrawStickman(); DrawHUD(); FlushBatchDraw(); Sleep(1); } EndBatchDraw(); closegraph();Sleep(1)这一句很多人会漏掉。它的作用是把当前线程让出 1 毫秒避免 CPU 跑满一个核心。不加的话你的游戏会占着 100% 的 CPU 转圈风扇呼呼响笔记本电池掉得飞快。加上之后 CPU 占用能降到个位数画面也完全看不出一毫秒的区别。场景分层也很重要。我一般分三层最底下的天空和远山中间的地面最上面的角色、障碍和分数。远山用视差滚动做出纵深感——背景以 0.6 像素每帧的速度移动地面以speed的速度移动两者速度差越大空间感越强。远山的循环位移用取模实现static float bgOffset 0.0f; bgOffset 0.6f; if (bgOffset 240.0f) bgOffset - 240.0f;按 240 像素的间距铺一排圆当山包位置超出屏幕就绕回来视觉上就是无限延伸的丘陵。3.2 输入响应跳跃缓冲与落地判定新手写的跳跃通常是这样的按下空格if (onGround) vy -JUMP_SPEED;。这个写法能跑但手感很硬玩家经常抱怨我明明按了怎么没跳。问题出在时序错位上。玩家在看到障碍的那一瞬间按下按键但这个按下的时刻角色可能还差两帧才落地。程序检测到onGround是false直接把这次输入丢掉了。玩家感受到的就是按键失灵。解决方案是加两个宽限计时器跳跃缓冲jump buffer和土狼时间coyote time。前者记录玩家最近按过跳后者记录角色最近刚离地。只要这两个窗口有重叠跳跃就允许触发。const float COYOTE 0.10f; // 离地后仍可起跳的宽限 const float BUFFER 0.12f; // 提前按键的缓存时长 if (jumpJustPressed) p.jumpBuffer BUFFER; else p.jumpBuffer - dt; if (p.onGround) p.coyoteTimer COYOTE; else p.coyoteTimer - dt; if (p.jumpBuffer 0.0f p.coyoteTimer 0.0f) { p.vy -JUMP_SPEED; p.onGround false; p.jumpBuffer 0.0f; p.coyoteTimer 0.0f; }这两个量看着不起眼实测下来对体感的提升是巨大的。0.1 秒的土狼时间大概相当于 6 帧0.12 秒的缓冲大概 7 帧正好覆盖人类反应和程序执行之间的误差。调过之后玩家会觉得这游戏很跟手但说不出具体哪里变了这就是好设计的标准状态。输入采集用 EasyX 的消息机制配合 Windows 的按键状态查询。前者负责捕捉刚刚按下这个瞬时事件后者负责判断此刻是否按住这个持续状态两者配合才能实现可变跳跃高度。ExMessage msg; bool jumpJustPressed false; while (peekmessage(msg, EX_KEY)) { if (msg.message WM_KEYDOWN msg.vkcode VK_SPACE) jumpJustPressed true; } bool jumpHeld (GetAsyncKeyState(VK_SPACE) 0x8000) ! 0;注意peekmessage会把消息从队列里取走所以在固定步长循环里jumpJustPressed这个标志用完一次就要立刻清掉否则同一次按键会触发多次跳跃判定。3.3 计时、计分与难度曲线计分我用的是一段一段累加的方式而不是直接拿距离算。维护一个scoreTimer每过 0.1 秒加一分。这样分数增长是离散的、可控的不会因为帧率波动导致分数忽快忽慢。难度曲线我用了最简单也最有效的线性模型speed BASE_SPEED score * 1.6再卡一个上限。为什么要有上限因为无限制增长下去障碍物移动速度会超过人眼能反应的极限游戏就从可以挑战变成了必死。上限设在 640 像素/秒左右大概是初始速度的两倍这个区间足够让人感受到压力又还留有一线生机。上限的存在还有另一个好处它给了高手一个稳定期。当速度封顶之后玩家可以靠肌肉记忆一直跑下去分数就成了纯粹的技术比拼。很多经典跑酷游戏都是这么设计的分数没有尽头但难度有天花板。障碍物的高度也要随机。我用的是 28 到 62 像素之间的均匀分布。为什么不是 20 到 80因为太矮的东西玩家可能直接跨过去甚至察觉不到太高的东西视觉上压迫感过强会让玩家误判难度。28 到 62 这个区间最高的柱子大概是玩家身高70 像素的九成跳过去需要一点准头但不过分。int hgt std::uniform_int_distributionint(28, 62)(g_rng); obs.push_back({ (float)WIDTH 40, (float)(GROUND_Y - hgt), 26, hgt, true });随机数这里我要提醒一句别用rand()。它的取值范围只有 0 到 32767而且低位随机性差在需要精细随机化的场合容易出问题。用 C11 引入的random头文件配合std::mt19937和std::uniform_int_distribution分布均匀、周期长是现在的标准做法。3.4 完整可运行代码与逐段注释把上面这些零件拼起来就是下面这份完整代码。我按常量区、数据结构、物理更新、碰撞、绘制、主循环的顺序组织你可以直接新建一个空项目贴进去编译。// StickmanRunner.cpp #include graphics.h #include vector #include random #include cmath #include algorithm // ---------------- 常量区 ---------------- const int WIDTH 960; const int HEIGHT 540; const int GROUND_Y 440; const float GRAVITY 2600.0f; // 像素/秒^2 const float JUMP_SPEED 850.0f; // 像素/秒 const float JUMP_CUT 320.0f; const float BASE_SPEED 320.0f; const double FIXED_DT 1.0 / 60.0; // ---------------- 数据结构 ---------------- struct Player { float x 160.0f; float y (float)(GROUND_Y - 70); float vy 0.0f; int w 30; int h 70; bool onGround true; float animPhase 0.0f; float coyoteTimer 0.0f; float jumpBuffer 0.0f; }; struct Obstacle { float x, y; int w, h; bool alive true; }; static std::mt19937 g_rng(std::random_device{}()); // ---------------- 障碍间距 ---------------- float NextGap(float speed) { float airTime 2.0f * JUMP_SPEED / GRAVITY; float travel airTime * speed; int low (int)(travel 60.0f); int high low 260; return (float)std::uniform_int_distributionint(low, high)(g_rng); } // ---------------- 玩家更新 ---------------- void UpdatePlayer(Player p, float dt, bool jumpHeld, bool jumpPressed) { const float COYOTE 0.10f; const float BUFFER 0.12f; if (jumpPressed) p.jumpBuffer BUFFER; else p.jumpBuffer - dt; if (p.onGround) p.coyoteTimer COYOTE; else p.coyoteTimer - dt; if (p.jumpBuffer 0.0f p.coyoteTimer 0.0f) { p.vy -JUMP_SPEED; p.onGround false; p.jumpBuffer 0.0f; p.coyoteTimer 0.0f; } // 松手截断实现长短按不同跳跃高度 if (!jumpHeld p.vy -JUMP_CUT) p.vy -JUMP_CUT; p.vy GRAVITY * dt; p.y p.vy * dt; if (p.y p.h GROUND_Y) { p.y (float)(GROUND_Y - p.h); p.vy 0.0f; p.onGround true; } else { p.onGround false; } p.animPhase dt * 14.0f; if (p.animPhase 6.2831853f) p.animPhase - 6.2831853f; } // ---------------- 碰撞 ---------------- bool AABB(const Player p, const Obstacle o) { return p.x o.x o.w p.x p.w o.x p.y o.y o.h p.y p.h o.y; } // ---------------- 火柴人绘制 ---------------- void DrawStickman(const Player p) { int cx (int)(p.x p.w / 2); int top (int)p.y; int headR 9; int neck top headR * 2; int hip (int)(p.y p.h * 0.62f); int foot (int)(p.y p.h); setlinestyle(PS_SOLID, 3); setlinecolor(RGB(235, 240, 255)); circle(cx, top headR, headR); line(cx, neck, cx, hip); float swing p.onGround ? sinf(p.animPhase) * 16.0f : 8.0f; int sw (int)swing; line(cx, neck 5, cx - 13 sw, neck 26); line(cx, neck 5, cx 13 - sw, neck 26); line(cx, hip, cx - 12 sw, foot); line(cx, hip, cx 12 - sw, foot); } // ---------------- 场景绘制 ---------------- void DrawScene(const Player p, const std::vectorObstacle obs, int score, bool gameOver) { // 天空 setfillcolor(RGB(24, 26, 38)); solidrectangle(0, 0, WIDTH, HEIGHT); // 远山视差 static float bgOffset 0.0f; bgOffset 0.6f; if (bgOffset 240.0f) bgOffset - 240.0f; setfillcolor(RGB(32, 38, 56)); for (int i -1; i 6; i) { int bx (int)(i * 240 - bgOffset); solidcircle(bx, GROUND_Y, 78); } // 地面 setfillcolor(RGB(46, 52, 74)); solidrectangle(0, GROUND_Y, WIDTH, HEIGHT); setlinecolor(RGB(120, 200, 255)); setlinestyle(PS_SOLID, 2); line(0, GROUND_Y, WIDTH, GROUND_Y); // 障碍 for (const auto o : obs) { setfillcolor(RGB(255, 118, 108)); solidrectangle((int)o.x, (int)o.y, (int)(o.x o.w), (int)(o.y o.h)); setlinecolor(RGB(255, 190, 180)); setlinestyle(PS_SOLID, 2); rectangle((int)o.x, (int)o.y, (int)(o.x o.w), (int)(o.y o.h)); } DrawStickman(p); // 分数 char buf[64]; sprintf_s(buf, SCORE %d, score); settextcolor(RGB(230, 235, 250)); settextstyle(28, 0, _T(Consolas)); outtextxy(30, 30, buf); if (gameOver) { settextcolor(RGB(255, 118, 108)); settextstyle(52, 0, _T(Consolas)); outtextxy(WIDTH / 2 - 130, HEIGHT / 2 - 40, _T(GAME OVER)); } } // ---------------- 主循环 ---------------- int main() { initgraph(WIDTH, HEIGHT); BeginBatchDraw(); Player p; std::vectorObstacle obs; float speed BASE_SPEED; float spawnCountdown 500.0f; int score 0; double scoreTimer 0.0; bool gameOver false; ULONGLONG last GetTickCount64(); double acc 0.0; while (true) { ULONGLONG now GetTickCount64(); double frame (now - last) / 1000.0; last now; if (frame 0.25) frame 0.25; acc frame; ExMessage msg; bool jumpPressed false; while (peekmessage(msg, EX_KEY)) { if (msg.message WM_KEYDOWN msg.vkcode VK_SPACE) jumpPressed true; } bool jumpHeld (GetAsyncKeyState(VK_SPACE) 0x8000) ! 0; if (!gameOver) { while (acc FIXED_DT) { UpdatePlayer(p, (float)FIXED_DT, jumpHeld, jumpPressed); jumpPressed false; speed BASE_SPEED score * 1.6f; if (speed 640.0f) speed 640.0f; for (auto o : obs) o.x - speed * (float)FIXED_DT; spawnCountdown - speed * (float)FIXED_DT; if (spawnCountdown 0.0f) { int hgt std::uniform_int_distributionint(28, 62)(g_rng); obs.push_back({ (float)WIDTH 40, (float)(GROUND_Y - hgt), 26, hgt, true }); spawnCountdown NextGap(speed); } for (const auto o : obs) if (AABB(p, o)) { gameOver true; break; } obs.erase(std::remove_if(obs.begin(), obs.end(), [](const Obstacle o) { return o.x o.w -60.0f; }), obs.end()); scoreTimer FIXED_DT; if (scoreTimer 0.1) { scoreTimer - 0.1; score; } acc - FIXED_DT; } } else { acc 0.0; if (jumpPressed) break; } DrawScene(p, obs, score, gameOver); FlushBatchDraw(); Sleep(1); } EndBatchDraw(); closegraph(); return 0; }代码里有个容易忽略的细节jumpPressed在固定步长循环内部被置为false而且是在UpdatePlayer调用之后。这是为了保证同一次按键在整个循环里只被消费一次防止一帧内跑了多个物理步导致连续起跳。4. 踩坑记录那些教程不会告诉你的问题4.1 画面闪烁与掉帧的三种成因画面问题我遇到过三大类成因各不相同得分开处理。第一类是清屏闪烁也就是单缓冲的锅。症状是画面整体忽明忽暗像老式日光灯管。解决方案就是前面说的BeginBatchDraw和FlushBatchDraw注意这两个函数必须成对使用而且FlushBatchDraw要放在所有绘制调用之后、Sleep之前。第二类是绘图元素过多导致的掉帧。我一开始给远山画了 20 个圆结果低配机器上帧率直接掉到 30。后来压缩到 5 个圆加上屏幕外的裁剪一下就顺了。经验是屏幕外的东西别画看不见的图形省下来就是帧率。第三类是浮点累积误差引起的抖动。表现是火柴人站稳之后身体还在细微地上下颤动一两像素。原因是每帧p.y p.vy * dt即使vy被置零因为重力在置零之前已经加过一轮y会被推下去一点点然后又被地面判定拉回来。解决办法是在落地处理时直接把y钉死到地面上而不是让它自己弹回去if (p.y p.h GROUND_Y) { p.y (float)(GROUND_Y - p.h); // 硬性吸附不留浮点尾巴 p.vy 0.0f; p.onGround true; }4.2 卡地、穿模、二段跳失控穿模是最吓人的一个问题速度快的时候火柴人直接穿过柱子飞过去判定完全没触发。这个现象的学名叫隧穿根源是离散步长太大。假设障碍宽 26 像素以 640 像素每秒的速度移动一帧 1/60 秒就走 10.7 像素看起来还安全。但如果你的帧间隔保护没做卡一下变成 0.25 秒一帧就是 160 像素柱子直接跳过了整个玩家。两个层面的防御一是把帧间隔上限卡在 0.25 秒以内二是把更新拆成固定小步长。这两个我都做了。如果还想更保险可以在碰撞检测时用扫掠方式把玩家矩形沿运动方向拉长成一个胶囊形状再判定但那属于进阶话题这个项目用不上。二段跳失控的表现是玩家能在空中一直跳像踩楼梯一样越跳越高。产生原因是coyoteTimer给的宽限太大玩家落地后离开地面宽限窗口还没关闭又触发了一次跳跃。解决办法是把COYOTE控制在 0.1 秒左右同时确保跳跃触发之后立刻把coyoteTimer和jumpBuffer都清零。我在代码里就是这么写的两个变量一起归零杜绝重复触发。还有一个隐蔽的坑onGround的更新顺序。如果你先判断跳跃、后更新物理那么跳跃的那一帧onGround还是上一帧的值这没问题。但如果你在跳跃之后又调用了落地检测可能会把刚起飞的角色又按回地面。我在UpdatePlayer里把落地检测放在物理积分之后跳跃判定放在最前面顺序上就不会打架。4.3 编译与运行库报错速查这类问题跟代码本身无关但新手最容易卡在这里。我把见过的几种整理成表格。报错信息根本原因解决方式找不到graphics.h没装 EasyX 或装到了错误的 VS 版本重新运行 EasyX 安装包勾选你实际使用的 VS 版本无法解析的外部符号_initgraph头文件找到了但库没链接上检查项目是 Debug/Release 与库文件的位数是否一致提示需要 C 14.0 或更高版本的工具集缺少 MSVC 运行库多见于别人的机器上运行你的 exe安装 Microsoft Visual C Redistributable 对应的 x64/x86 版本中文注释乱码源文件编码与编译器解析编码不一致在源文件属性里把编码设为 UTF-8 带 BOM或改用英文注释编译通过但窗口一闪而过程序正常跑完就退出了没进主循环检查while(true)是否被误删或加了断点调试第二行那个库没链接上尤其常见。有人在网上抄了一份配置教程照着配了include目录但忘了配lib目录编译期能过链接期就炸。EasyX 的安装包会自动处理这些所以我的建议永远是别手动配直接用安装程序。至于那个微软 C 运行库缺失的报错本质上是你的程序依赖 MSVC 的运行时 dll而目标机器上没装。这不是你代码的问题把 redistributable 装一下就好。如果要把 exe 发给朋友玩记得提醒对方装这个运行库或者用静态链接的方式编译把运行时打包进 exe 里。4.4 越界访问与容器失效用vector管理障碍物的时候有两个经典陷阱。第一个是遍历中删除。我见过有人这么写for (int i 0; i obs.size(); i) if (obs[i].x -60) obs.erase(obs.begin() i);。删掉一个之后后面的元素全部前移i却已经自增了于是跳过了一个元素。如果容器里只剩一个元素被删掉size()变成 0下一轮循环访问obs[0]就直接越界崩溃。正确做法是用 C 的std::remove_if配合erase也就是擦除-移除惯用法obs.erase(std::remove_if(obs.begin(), obs.end(), [](const Obstacle o) { return o.x o.w -60.0f; }), obs.end());remove_if会把要保留的元素挪到前面返回一个指向新逻辑结尾的迭代器erase再把这个尾巴砍掉。这个组合是安全的而且语义清晰。注意remove_if不是真的删除只是重排所以必须跟erase配对使用单独用remove_if是没效果的。第二个陷阱是引用失效。vector扩容的时候会重新分配内存之前拿到的元素引用、指针、迭代器全部作废。所以别写Obstacle ref obs[0];然后中间又push_back一个新障碍。我在代码里用的是范围 for 加引用但push_back是在另一个循环里做的两者不交叉所以是安全的。如果你要在遍历中生成新障碍改成记录索引或者先用临时变量攒起来遍历结束后统一插入。5. 调到手感舒服为止参数表与玩法扩展5.1 一份可以直接抄的参数表调参是跑酷游戏最耗时间的环节我把自己反复试出来的一组数值整理在下面。这套参数在 960×540 分辨率下表现比较均衡你可以直接拿去当起点然后按自己的喜好微调。参数名推荐值单位调大后的感受GRAVITY2600像素/秒²落地更快整体节奏更硬JUMP_SPEED850像素/秒跳得更高滞空更久JUMP_CUT320像素/秒短按跳得更高可变跳跃感变弱BASE_SPEED320像素/秒初始难度变高速度上限640像素/秒后期更难考验反应极限COYOTE0.10秒容错更大但过大容易二段跳BUFFER0.12秒提前按键更容易被识别障碍高度28~62像素障碍更高视觉压迫更强有个调参的通用原则一次只改一个参数改完立刻试玩十几秒。同时改三个参数你根本不知道是哪个起的作用。我调这套数值大概花了一个晚上中间来回改了二十多轮光是GRAVITY就在 2000 到 3000 之间来回扫了五遍。另外提一句分辨率变了之后这套参数不一定还适用。因为位移和跳跃高度都是按像素算的屏幕从 960 拉宽到 1920视觉上的相对高度就变了。如果要适配不同分辨率把所有像素常量换成相对于屏幕高度的比例值这个改造不难但要在一开始就设计好中途改会比较痛苦。5.2 几个低成本高回报的扩展方向代码能跑之后如果还想继续玩下去我推荐几个改动量小但效果明显的方向。第一个是二段跳。允许玩家在空中再跳一次coyoteTimer的逻辑稍微改一下落地时重置一个airJumpCount起跳时消耗一次。这个改动不超过十行代码但玩法立刻丰富了一档因为玩家可以用第二跳去够一些原本够不到的高度障碍设计也可以变得更激进。第二个是障碍物多样化。现在只有地面柱子一种可以加飞行障碍悬在半空需要从下面钻过去、高矮组合两根柱子挨着一高一矮需要精准落点、移动障碍上下浮动的柱子。每加一种关键就是在生成逻辑里按权重随机种类绘制和碰撞代码几乎不用改。第三个是加速度感和视觉反馈。当速度提升到某个阈值时给背景加一点模糊感或者拖影效果让玩家在视觉上也能感知到我在变快。最简单的做法是把远山的移动速度从 0.6 提升到 2.0同时把地面纹理的线条密度加大。这种速度感的营造往往比实际数值的提升更能让玩家肾上腺素飙升。第四个是音效。EasyX 本身不带音频播放但你可以用 Windows 的PlaySound接口播 wav 文件起跳、落地、得分、撞击各配一个短音效。别小看这几个音效它们对操作反馈的强化作用是巨大的——玩家很多时候是通过声音确认自己按键生效了而不是通过画面。最后一个建议是关于代码结构的。现在所有东西都在一个 cpp 里等你加了二段跳、四种障碍、音效之后再想改就会发现找代码成了噩梦。所以趁早把Player、Obstacle、Renderer、GameWorld拆成独立的类物理更新、渲染、输入各自剥离。这个过程本身也是一次很好的 C 面向对象练习比这个游戏本身更能提升你的工程能力。我在实际做这个项目的过程中最大的体会是游戏开发真正难的不是把画面画出来而是把感觉调到对。同一份代码参数差一点点玩起来就是天壤之别。所以别急着往下加功能先把最基础的跳跃和落地打磨到自己愿意反复玩十几把的程度再谈扩展。那种明明只改了一个数手感突然就对了的瞬间是这个方向最让人上头的部分。