☰
从Dev-C++到可玩跑酷小游戏4.0.0:主循环、碰撞与手感调优
2026/10/27 14:34:24 网站建设 项目流程

跑酷小游戏这东西,放在 Dev-C++ 里写,第一次跟人说的时候大概率会被问一句“你怎么不用 Unity”。但真坐下来把一个跑酷小游戏从 1.0 迭代到 4.0.0,你会发现这个“老掉牙”的组合反而是练基本功最快的方式:没有引擎帮你处理渲染循环,没有物理系统帮你算重力,没有场景管理器帮你切状态,所有东西都得自己一行行敲出来,敲完你就真懂了。这篇就聊聊我这边跑酷小游戏 4.0.0 这个版本的整体设计、核心机制的数值推导、实操落地过程,以及那些文档里不会写、只有真跑起来才会撞上的坑。适合已经会点 C++ 语法、想在 Dev-C++ 里做出一个“能玩、手感不别扭、还能打包发给别人玩”的完整小作品的人看,也适合带学生做课程设计的同行参考。

1. 这个 4.0.0 版本到底改了什么:整体设计与技术选型

1.1 为什么还留在 Dev-C++ 里做这件事

先说清楚定位:这个项目的目标从来不是做商业级产品,而是做一个“单文件可编译、零安装依赖、双击就能跑”的练习作品。Dev-C++ 在这件事上有它不可替代的位置——体积小、装完即用、MinGW 工具链自带、编译一个 cpp 就是一瞬间的事,学生机上跑起来毫无压力。换成大型 IDE,光配置工程和第三方库就能劝退一半人。

但代价也很明确:Dev-C++ 自带的模板和库生态停在很多年前,碰到图形、音频、窗口这些东西,基本等于从零开始。所以这个项目的设计原则第一条就是把外部依赖压到最低,第二条是所有平台相关的代码集中到一个薄薄的封装层,这样将来想换编译器或者换渲染后端,改动量可控。

我踩过的最大的坑,是早期版本把 Windows API 调用散落在游戏逻辑里,结果想加一个“窗口尺寸自适应”功能,改了四十多个地方。从 3.x 开始我就强制自己分层:platform层负责窗口和绘制,core层负责游戏逻辑,game层负责具体的跑酷规则。4.0.0 沿用这个结构,只是把platform层重写了一遍。

1.2 图形方案的三条路和最终选择

在 Dev-C++ 里画东西,实际上只有三条路可走,各自的取舍差别非常大。

第一条是纯控制台字符画。用printf配合光标定位(SetConsoleCursorPosition)刷字符矩阵。优点是零依赖、编译即通过;缺点是刷新率上不去,一个 80x25 的控制台刷一次屏,稍微复杂点的画面就开始拖影,而且没法做平滑移动,只有“格子跳”。这个方案我只在 1.x 版本用过,做出来能跑,但看着像 1985 年的东西。

第二条是 Win32 GDI 自绘。自己注册窗口类、写消息循环、拿 HDC 往上 BitBlt。优点是零第三方依赖,编译参数加-lgdi32 -luser32就行;缺点是代码量大,光是搭一个能用的窗口骨架就要两百行,而且 GDI 的绘图接口偏底层,画个圆角矩形都得自己拼路径。

第三条是用现成的图形库。这里有个很多人不知道的细节:网上教程里那些#include <graphics.h>的代码,绝大多数是 Turbo C 时代的 BGI 写法,在现代 MinGW 下根本编译不过。EasyX 虽然接口跟 BGI 很像,但它官方的支持重心在 MSVC 系列,MinGW 下的支持一直比较别扭,配置起来容易卡住。真正对 MinGW 友好、能在 Dev-C++ 里直接用的是 EGE(Easy Graphics Engine),它同样是 BGI 风格接口,但明确面向 MinGW/GCC 编译,支持initgraph、setcolor、outtextxy这一套,迁移老代码几乎无痛。

4.0.0 的最终选择是 EGE 为主干,同时保留一个纯 GDI 的降级分支。理由很实际:EGE 能让我用十几行代码把画面跑起来,把精力集中在游戏逻辑上;而保留 GDI 分支,是为了在完全拿不到第三方库的环境里(比如某些机房的机器不让装东西)还能交付一个能跑的版本。两条分支共用同一个platform接口,上层代码一行不改。

提示:如果你手里的 Dev-C++ 是较老的版本,注意确认编译器是 32 位还是 64 位。第三方图形库的.a文件和编译器位数必须一致,混用会在链接阶段报一堆undefined reference,而这个报错信息完全不会告诉你真正原因是位数不对。

1.3 4.0.0 相对旧版的结构性调整

从 1.0 到 3.x,这个项目一直是“一个 main.cpp 从头写到尾”的风格,好处是改起来直观,坏处是到 3.x 的时候单文件已经快两千行,加个功能得先滚半天找位置。4.0.0 做的最重要的一件事其实是拆文件,拆成六个模块:

模块职责大致行数
main.cpp程序入口、主循环调度120
platform.h/cpp窗口、绘制、输入、计时400
physics.h/cpp重力、跳跃、AABB 碰撞180
spawner.h/cpp障碍生成、难度曲线220
statemachine.h/cpp菜单/游戏中/暂停/结算切换200
assets.h/cpp图片、音效、字体缓存260

拆完之后有两个立竿见影的好处。一是编译速度反而变快了,因为改一个小文件只重编那一份;二是调参变简单了,所有跟手感有关的数字全部集中到physics.cpp顶部的一个常量区,改完重编三秒就能试。

另一个结构性调整是把“难度”从一个隐式的、散落在各处的变量,变成了一条显式的曲线。3.x 里速度是每帧speed += 0.001,加着加着就失控;4.0.0 改成按“已跑距离”分段查表,每一段的起止速度和障碍密度都是固定写死的,可控性完全是两个量级。

2. 核心机制拆解:跑酷游戏的骨架到底是什么

2.1 主循环与时间步长:所有手感问题的源头

跑酷游戏的“手感”这个词听起来玄,但拆开看,七成以上的问题都出在主循环的写法上。我见过最多的写法是这种:

while (!gameover) { update(); render(); Sleep(16); }

这段代码在快机器上跑起来是丝滑的,但它的物理是“帧驱动”的——每帧重力加一个固定值,跟这一帧实际过了多少毫秒完全无关。结果就是同一个人同一台电脑,开个浏览器再切回来,跳跃高度就变了;发给别人玩,对方电脑快一点,人物跳得高一倍。这就是所谓的“物理跟帧率绑定”,是老式小游戏最经典的毛病。

4.0.0 的解法是固定时间步长 + 累加器。物理更新永远以 1/60 秒为一步,主循环只负责“攒够了就更新一步,没攒够就等”。这样不管机器多快多慢,物理表现完全一致。

const double STEP = 1.0 / 60.0; double accumulator = 0.0; double lastTime = now_seconds(); while (running) { double cur = now_seconds(); double frame = cur - lastTime; lastTime = cur; if (frame > 0.25) frame = 0.25; // 防止切窗口回来时"追帧爆炸" accumulator += frame; while (accumulator >= STEP) { update_physics(STEP); // 物理只认 STEP accumulator -= STEP; } render(); // 渲染按实际节奏 limit_fps(60); }

那段if (frame > 0.25) frame = 0.25;是必须有的。切出去看个网页回来,frame可能是好几秒,没有这个钳制,程序会一次性跑几百步物理,人物瞬间窜出屏幕,看起来就像“卡死了一下然后瞬移”。这行代码算是血泪经验,被坑过一次就再也忘不掉。

now_seconds()在 Windows 下用QueryPerformanceCounter或者chrono::steady_clock都行,后者可读性好一些。别用time(NULL),精度只有一秒,完全没法用。

2.2 跳跃与重力的数值推导

这一段是很多人做跑酷游戏最容易糊弄过去的地方——凭感觉给重力赋个0.5,跳跃赋个-10,能跳起来就算完事。但这样做的直接后果是:关卡没法设计。你不知道人物最高能跳多高,就不知道该把障碍物放多高;你不知道滞空多久,就不知道该把障碍物间隔留多宽。

正确的顺序是先定手感目标,再反推参数。我们先定两条约束:人物跳跃的最高点大约是身高的 2.5 倍(跑酷游戏常见的“跳得很爽”的比例),滞空总时间控制在 0.7 秒左右(人类反应和节奏感比较舒服的区间)。

设重力加速度为g(单位:像素/秒²),起跳初速度为v0(向上为负),则:

  • 到达最高点的时间:t_up = v0 / g
  • 总滞空时间:t_total = 2 * v0 / g
  • 最大跳跃高度:h = v0² / (2g)

如果角色身高 40 像素,跳跃高度目标是 100 像素,滞空 0.7 秒,把它代进去:

t_total = 2v0/g = 0.7→v0 = 0.35g

h = v0² / (2g) = (0.35g)² / (2g) = 0.06125g = 100

解出来g ≈ 1633 像素/秒²,v0 ≈ -571 像素/秒。这两个数字看着很怪,但它们是物理上自洽的,落到代码里就是每帧的量:

const float GRAVITY = 1633.0f; // 像素/秒² const float JUMP_VEL = -571.0f; // 像素/秒,向上为负 const float GROUND_Y = 480.0f; // 地面高度 // 每步(STEP = 1/60) vy += GRAVITY * STEP; // 约 +27.2 像素/秒每步 y += vy * STEP; // 位移 if (y >= GROUND_Y) { y = GROUND_Y; vy = 0; onGround = true; }

再往下推一层,还有个更关键的参数:水平速度决定了跳跃的水平覆盖距离。假设水平速度是 360 像素/秒,滞空 0.7 秒,那么一次跳跃能横跨 252 像素。这意味着两个相邻障碍物之间的净间距如果小于这个数减去角色宽度,那就有可能出现“落地即撞”的死局。所以障碍生成器里的最小间距,必须至少是跳跃横跨距离 + 角色宽度 + 安全余量,我一般取 1.3 倍余量,也就是大约 330 像素起步。

这个推导看起来啰嗦,但它是整个关卡设计的数学基础。跳过这一步直接调参,最终一定会陷入“改了这个那个坏”的死循环。

2.3 碰撞判定的盒子怎么画

跑酷游戏的碰撞检测本质就是 AABB(轴对齐包围盒)相交,逻辑本身很简单,难的是“判定盒该多大”。

新手最常见的做法是直接用贴图尺寸当判定盒。这在实际游戏里几乎必然是错的——角色贴图通常带描边、阴影、发光边缘,按贴图算会有大量“明明没碰到却判定死了”的情况,玩家感受极差。

4.0.0 的做法是给每个实体定义两套盒子:renderBox用于绘制,hitBox用于碰撞,后者比前者小一圈,并且在垂直方向上明显收缩。

struct AABB { float x, y, w, h; }; bool intersects(const AABB& a, const AABB& b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; } // 从渲染盒推导判定盒:水平收缩 20%,垂直收缩 25%,并对齐到底部 AABB make_hitbox(const AABB& r) { AABB h; h.w = r.w * 0.60f; h.h = r.h * 0.75f; h.x = r.x + (r.w - h.w) * 0.5f; h.y = r.y + (r.h - h.h); // 脚底对齐 return h; }

为什么垂直方向要底部对齐而不是居中?因为跑酷游戏里玩家的心理预期是“脚踩在地上”,如果判定盒居中,人物明明脚已经落到平台上了却被判定为悬空,那种感觉非常割裂。底部对齐则符合直觉:脚碰到就是碰到。

聊到这里必须提一个 4.0.0 里加的东西:碰撞盒的时间采样。当水平速度很快的时候,一帧的位移可能超过障碍物宽度,人物就会“穿过去”——上一帧在障碍左边,下一帧在右边,中间没有一帧是重叠的。解决办法是把一步的位移拆成若干子步,每个子步做一次碰撞检测:

int subSteps = std::max(1, (int)std::ceil(std::abs(dx) / (MIN_OBSTACLE_W * 0.5f))); for (int i = 0; i < subSteps; ++i) { player.x += dx / subSteps; if (check_collision(player)) { /* 触发死亡 */ break; } }

这个技巧叫连续碰撞检测的简化版,代价很小,但能彻底消灭高速穿透问题。速度上限提到 900 像素/秒之后,不加这个必出 bug。

2.4 障碍生成与难度曲线

障碍生成这块,最省事的写法是每帧if (rand() % 100 == 0) 生成一个,但这样写出来的游戏会非常难玩——因为随机就是随机,它不保证间距,也不保证公平性。

4.0.0 用的是预生成队列 + 约束校验。维护一个待生成的障碍队列,每次从屏幕右侧外生成时,先检查“上一个障碍的右边缘到新的障碍的左边缘”这个间距是否满足当前速度下的最小安全间距,不满足就往后推。

float minGap(float speed) { float airTime = 2.0f * std::abs(JUMP_VEL) / GRAVITY; // 约 0.70s float span = speed * airTime; // 一次跳跃横跨距离 return span + PLAYER_W + 60.0f; // 加安全余量 }

难度曲线我最终定成了四段式,每段对应一个速度区间和障碍类型权重:

阶段已跑距离水平速度(px/s)障碍类型权重说明
热身0 - 800340 → 400单块地刺 100%让玩家熟悉跳跃节奏
进阶800 - 2200400 → 520单块 70%、双联 20%、高块 10%引入时机变化
高压2200 - 4000520 → 680单块 40%、双联 35%、高块 25%需要连跳
极限4000+680 → 900(封顶)单块 25%、双联 40%、高块 35%纯考验反应

速度不是一直涨到无穷,而是在 900 封顶,之后只提升障碍密度。这个“封顶”很重要——不封顶的话,游戏在第 90 秒之后会变成纯粹的按键机器,玩家只能祈祷运气好,那就没有乐趣可言了。

3. 从零到能跑:关键环节的实操实现

3.1 工程配置与编译参数

Dev-C++ 里新建工程,类型选Console Application(不是 Windows Application),因为 EGE 需要一个控制台窗口来输出调试信息,重定向到文件很方便。工程建好后,右键工程 → 项目属性 → 参数,把库文件加进去。

如果你用的是 EGE,需要先拿到对应编译器位数的库文件,通常包含libgraphics.a(或者libege.a)和libgdiplus.a之类,放进 MinGW 的lib目录,头文件放include目录。编译参数里加:

-lgdi32 -luser32 -lwinmm -limm32 -lole32 -loleaut32 -luuid -lgdiplus -lgraphics

这里的顺序有讲究,-lgraphics一般要放在最后。链接器是按顺序解析的,先解析的库如果依赖后面的库,后面的必须排在后面,否则会报一堆undefined reference,而且报错函数名看起来跟你写的代码毫无关系,特别迷惑。

如果走纯 GDI 降级分支,参数简化成:

-lgdi32 -luser32 -lwinmm -mwindows

-mwindows的作用是不要弹出那个黑框控制台。调试阶段建议先不要加,能看见printf输出;发布前再加。

最后提一句静态链接。默认编译出来的 exe 依赖libstdc++-6.dll、libgcc_s_dw2-1.dll这些,发给别人大概率会报“找不到 xxx.dll”。加上这几个参数就能变成一个独立 exe:

-static -static-libgcc -static-libstdc++

加完之后体积会从一百多 KB 涨到一 M 多,但换来的是“拷过去就能跑”,非常值。

3.2 双缓冲消除闪屏

第一次把画面搬上屏幕,几乎百分之百会遇到严重闪烁——每一帧先清屏成黑色,再画所有元素,清屏和绘制之间那个空档被显示器抓到了,就是满屏闪。

解决思路是双缓冲:所有绘制先画到一块不显示的内存位图上,画完一次性贴到窗口。用纯 GDI 实现如下:

struct DoubleBuffer { HDC memDC = nullptr; HBITMAP bitmap = nullptr; HBITMAP oldBmp = nullptr; int w = 0, h = 0; void init(HDC screen, int width, int height) { w = width; h = height; memDC = CreateCompatibleDC(screen); bitmap = CreateCompatibleBitmap(screen, w, h); oldBmp = (HBITMAP)SelectObject(memDC, bitmap); } HDC dc() const { return memDC; } void present(HDC screen) { BitBlt(screen, 0, 0, w, h, memDC, 0, 0, SRCCOPY); } void destroy() { SelectObject(memDC, oldBmp); DeleteObject(bitmap); DeleteDC(memDC); } };

关键点在于CreateCompatibleBitmap必须用屏幕 DC来创建,而不是内存 DC。用内存 DC 创建出来的是单色位图,画上去全是黑白,这个坑我踩过一次,调了半天以为是颜色常量写错了。

如果你用 EGE,它内部已经处理了缓冲,但要注意别在绘制循环里做耗时操作。EGE 有一个setrendermode(RENDER_MANUAL)的手动模式,跟上面 GDI 的思路是一致的,效果上相当于把present的时机交给你控制。

另外一个隐性闪烁源是每帧重新创建字体和画笔。GDI 的CreateFont、CreatePen都是相对重的操作,一帧调几十次会明显拖慢。正确做法是在初始化阶段建好,全程复用,退出时统一DeleteObject。

3.3 状态机与菜单流转

跑酷游戏看着简单,但状态其实不少:主菜单、游戏中、暂停、结算、排行榜。3.x 版本用一堆if (state == 1) ... else if (state == 2) ...硬写,加一个“暂停”功能改了七八处判断,非常痛苦。

4.0.0 换成了显式的状态机。每个状态有自己的enter、update、render、exit,切换时先调exit再调新状态的enter:

enum class GameState { Menu, Playing, Paused, GameOver }; class StateMachine { public: void change(GameState next) { if (current_ == next) return; if (onExit_) onExit_(current_); current_ = next; if (onEnter_) onEnter_(current_); } GameState current() const { return current_; } private: GameState current_ = GameState::Menu; std::function<void(GameState)> onEnter_, onExit_; };

这个改动的价值在“暂停”功能上体现得最明显。暂停的本质不是“停止渲染”,而是停止物理更新但继续渲染。如果物理更新和渲染写在同一个函数里,暂停就只能靠一个全局bool paused到处判断。分开之后,主循环里只需要一句:

if (sm.current() == GameState::Playing) { while (accumulator >= STEP) { update_physics(STEP); accumulator -= STEP; } } render(); // 无论什么状态都渲染,暂停时画面就是定格

还有一个容易被忽略的细节:从暂停恢复时要重置时间基准。暂停期间lastTime没有更新,恢复后第一次算frame会得到一个巨大的值,虽然被前面的 0.25 钳制住了,但更干净的做法是在暂停时把accumulator清零、lastTime重置为当前时间。

3.4 资源加载与音效

资源管理这块,4.0.0 只做了一件事:所有资源在进入游戏前一次性加载,全程不重复读盘。

图片用一张大的图集(atlas),所有精灵画在一张 png 上,游戏里通过源矩形坐标截取。这样做的好处是减少文件句柄和加载次数,而且对老式 GDI 的位图操作更友好。加载代码大致是:

struct Sprite { HBITMAP bmp; int sx, sy, sw, sh; // 图集中的源矩形 }; std::vector<Sprite> load_atlas(const wchar_t* path, int cellW, int cellH) { HBITMAP bmp = (HBITMAP)LoadImageW(nullptr, path, IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE | LR_CREATEDIBSECTION); // 按行列切分成若干 Sprite ... }

注意LoadImageW加载 png 是不行的,它只认 bmp。想用 png 得靠 GDI+ 或者第三方库。这是个很实际的取舍——为了少引一个依赖,4.0.0 的图集最终用了 bmp 格式,代价是文件大一点,但打包简单。

音效用PlaySound播放 wav,异步模式:

PlaySoundW(L"jump.wav", nullptr, SND_FILENAME | SND_ASYNC | SND_NODEFAULT);

这里有个坑:SND_ASYNC模式下,如果连续快速触发同一个音效(比如连跳),后一次会打断前一次,听感上是“跳了但没声音”。真正的游戏会用混音通道,但在这个体量下不值得引入音频库。我的处理方式是给跳跃音效加一个最小间隔(比如 80 毫秒内的重复触发直接丢弃),听感上反而更干净。

注意:音量控制不要用系统音量。PlaySound本身没有音量参数,想调音量只能靠预先处理好的多个音量版本的 wav,或者上waveOut系列 API 自己做混音。这个项目选了前者,简单粗暴但够用。

3.5 存档与打包分发

最高分这种东西,最省事的写法是写一个文本文件。但直接写会碰上两个问题:一是路径,用相对路径的话,从不同目录启动程序会读不到;二是被玩家手动改。

路径问题的标准解法是用SHGetFolderPath拿到%APPDATA%目录,在下面建自己的文件夹:

wchar_t path[MAX_PATH]; SHGetFolderPathW(nullptr, CSIDL_APPDATA, nullptr, 0, path); std::wstring dir = std::wstring(path) + L"\\RunnerGame"; CreateDirectoryW(dir.c_str(), nullptr); std::wstring file = dir + L"\\save.dat";

至于防篡改,说实话在这个体量下没必要上加密。我加了一个很轻的校验:文件内容存成分数,校验值,校验值是分数乘一个常数再异或一个魔数。玩家真想改还是能改,但至少不会因为手滑改坏导致程序崩溃——读取时校验不过就当成 0 分,程序正常启动。对一个小游戏来说,崩溃比作弊严重得多。

打包方面,最终交付的是一个文件夹,里面包含 exe、assets目录(图集和音效)、一个readme.txt。静态链接之后不需要任何 dll,整个包大概 3-5 MB。压缩成 zip 发出去,对方解压双击就能玩,这是我认为这个项目最舒服的地方。

4. 手感调优:让“能玩”变成“想再玩一次”

4.1 三个宽容度机制

到 4.0.0 为止,我认为对手感提升最大的是三个“宽容度”机制。它们都不改变游戏规则,只是让玩家的操作意图更容易被正确理解。

第一个是离地宽容(coyote time)。人在跑出平台边缘之后,仍然有大约 100 毫秒可以起跳。现实里人踩空的一瞬间脚还是能发力的,游戏里同理,如果没有这个机制,玩家会觉得“我明明按了但没跳”。实现非常简单:记录最后一次站在地面上的时间戳,起跳时判断当前时间减去它是否小于阈值:

if (onGround) lastGroundedTime = now; bool canJump = (now - lastGroundedTime) < 0.10;

第二个是跳跃缓冲(jump buffer)。玩家在落地前 120 毫秒内按了跳,落地瞬间自动触发跳跃。跑酷游戏的节奏很快,玩家往往会提前按,没有缓冲的话这些提前按全部无效,体验上就是“按了没反应”。同样是一个时间戳的事:

if (jumpPressed) jumpBufferTime = now; if (onGround && (now - jumpBufferTime) < 0.12) { doJump(); jumpBufferTime = -1; }

第三个是可变跳跃高度。按住跳跃键跳得高,轻点一下跳得矮。实现方式是松手时把向上的速度截断一半:

if (jumpReleased && vy < 0) vy *= 0.5f;

这三个机制加起来的代码量不超过 30 行,但对手感的改善是数量级的。4.0.0 之前,测试反馈里出现频率最高的抱怨就是“跳不准”和“按了没反应”,加上这三个之后基本消失了。

4.2 参数表与调参方法

调参这件事,我的经验是一次只改一个参数,改完至少跑三局再判断。同时改三个参数,你永远不知道是哪个起的作用。

下面是我最终定下来的核心参数表,可以直接抄作业:

参数值说明
重力加速度1633 px/s²影响跳跃的“坠手感”
起跳初速度-571 px/s与重力配合决定高度和滞空
最大跳跃高度约 100 px约 2.5 倍角色身高
总滞空时间约 0.70 s低于 0.5 会觉得飘,高于 0.9 会觉得拖
初始水平速度340 px/s慢到能让新手反应
速度上限900 px/s再快就超出人的反应极限
离地宽容100 ms再长会显得“会飞”
跳跃缓冲120 ms再长会误触
判定盒水平收缩40%跑酷游戏的良心设置
判定盒垂直收缩25%底部对齐

调参的工具我强烈建议自己做一个调试面板:游戏里按一个键(比如 F1)切换显示碰撞盒、当前速度、帧时间、累计距离。这个面板的开发成本不到一小时,但它能让所有调参从“凭感觉”变成“看着数字调”。4.0.0 里这个面板还在,发布版用宏关掉,开发版一直开着。

5. 常见问题与排查实录

5.1 编译期问题速查

这个项目的读者大多数会卡在编译阶段,下面这张表是我收集到的高频问题,覆盖了九成以上的情况:

报错/现象真实原因处理方式
undefined reference to 'initgraph'库文件没加或链接顺序不对把图形库放在链接参数最后
大量符号找不到,函数名不在自己代码里32/64 位库与编译器位数不匹配换成对应位数的库文件
CreateFontW一类函数找不到没链gdi32参数补-lgdi32
编译通过但双击 exe 提示缺 dll动态链接了运行库加-static -static-libgcc -static-libstdc++
中文注释或字符串乱码源码编码与执行编码不一致源文件存 UTF-8,编译参数加-fexec-charset=GBK
找不到graphics.h头文件目录没配在“包含目录”里加上库的 include 路径

其中中文乱码这条值得单独说两句。Dev-C++ 默认保存的编码经常是 GBK,而 GCC 的默认执行字符集在不同版本上表现不一,结果就是控制台输出中文变成一堆问号。最稳的做法是源代码统一存成 UTF-8 无 BOM,编译时明确指定-fexec-charset=GBK(因为 Windows 控制台默认代码页是 GBK)。如果还不行,直接用SetConsoleOutputCP(936)在程序开头强制设置控制台代码页,基本能解决。

5.2 运行期问题速查

运行期的问题更隐蔽,因为编译器不会报错,只是行为不对:

现象排查方向解决思路
画面疯狂闪烁是否每帧清屏后直接画到窗口上双缓冲
跳跃高度随机变化物理更新是否跟帧时间绑定改固定步长
速度快了以后会穿过障碍单帧位移大于障碍宽度加子步采样
按住方向键人物只动一下用了getch这类阻塞式读取改成查询键盘状态
切出窗口再切回来人物瞬移累加器没钳制单帧时间上限 0.25s
音效连续触发只响一次异步播放被打断加最小触发间隔
长时间运行内存缓慢上涨GDI 对象泄漏检查CreateXxx是否都有对应的DeleteXxx

那个“按住方向键只动一下”的坑值得展开。很多教程里教的是getch(),它在控制台里是非阻塞的,但在图形窗口里如果没有按键事件就会一直等。正确做法是用GetAsyncKeyState查询按键当前状态,配合自己维护的keyDown/keyUp边沿检测:

bool is_down(int vk) { return (GetAsyncKeyState(vk) & 0x8000) != 0; }

用它来判断“这一帧按下、上一帧没按下”就得到按下沿,反过来就是抬起沿。跳跃这种动作一定要用按下沿触发,否则按住不放会连续起跳。

5.3 两个真实踩坑记录

第一个坑是内存位图的兼容性。前面提过CreateCompatibleBitmap要用屏幕 DC,我这里再补一句:如果你在初始化双缓冲的时候传进去的是一个还没真正显示过的 HDC(比如刚GetDC但窗口还没ShowWindow),创建出来的位图可能是 1 位色的,所有绘制都变成黑白。表现是“画面能出来但全是黑白两色”,非常诡异。解决方式是确保窗口已经显示并且UpdateWindow之后再去创建缓冲。

第二个坑是速度曲线和障碍生成的耦合。设计难度曲线时,我先写了速度随距离线性增长,然后障碍生成用“每次生成后随机等一段时间再生成下一个”。跑起来发现,中后期会出现连续的“不可能通过”组合:两个障碍挨得太近,中间没有落地点。原因是生成间隔是按秒算的,但速度在涨,同样的秒数对应更长的距离——可是障碍的宽度是固定的,间距算出来就不够了。

修法是把生成逻辑从“按时间间隔”改成“按距离间隔”,并且距离间隔的下限随速度动态计算,也就是前面minGap()那个函数。改完之后,任何速度下都能保证存在至少一条可通过的路径。这个改动只涉及二十来行代码,但它把游戏从“后期靠运气”变成了“后期靠技术”,性质完全不一样。

提示:调试这类“偶发不可能通过”的问题,最好的办法是加一个自动游玩模式——用简单的规则 AI(看到前方有障碍就跳),让它跑几万局,记录出现碰撞的位置和当时的参数。这比人肉测试快几百倍,而且能覆盖到人手动根本打不到的高分区间。

6. 后续扩展方向

4.0.0 这个版本,我自己认为是“完整”了:有完整的菜单流转、有难度曲线、有存档、有音效、能打包发给别人玩。但它显然还有很多可以长的地方,我列几个自己已经在动手的方向。

第一个是多角色。现在所有参数都写死在physics.cpp里,改成从配置文件读,就能做出“跳得高但跑得慢”“跑得快但跳得矮”的差异化角色。这本质上是个数据驱动的重构,做完之后加新角色只需要改一个配置表,不用碰代码逻辑。

第二个是关卡编辑器。现在障碍是程序生成的,如果能手动摆放并保存成文件,游戏的可玩内容会瞬间翻十倍。技术难度不高,主要是要设计一个能用的编辑界面,而且要让编辑器和游戏共用同一套实体定义。

第三个是重放系统。记录每一帧的按键状态和随机种子,就能完整复现一局游戏。这东西做出来之后,调试会变得极其方便——玩家报“这局有问题”,你拿到种子和按键记录就能一模一样地重现。对小团队甚至单人开发来说,这是性价比极高的一个功能。

最后一个方向是跨编译器兼容。现在这套代码在 MinGW 下跑得很好,但如果想让它也能在其他 GCC 环境下编译,需要把 Windows 相关的调用全部抽到platform层后面。这个重构在 4.0.0 里已经做了一半,剩下的主要是文件路径、线程和窗口消息这几块。做完之后,同一份游戏逻辑可以换不同的渲染后端,甚至可以跑在别的平台上,这对一个练手项目来说,算是把“学到的知识”真正变成“可复用的资产”了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询