☰
C++EasyX小游戏模板:游戏循环、双缓冲与固定步长
2026/10/27 13:48:12 网站建设 项目流程

做过几年图形方向的教学项目,也帮不少人改过他们自己写的小游戏,我发现一个特别有意思的现象:几乎每个人入门时写的第一版代码都是“一个 main 函数三百行”,能跑起来,但只要想加一个功能就全线崩溃。这份 C++EasyX小游戏模板 就是在这种背景下被反复打磨出来的东西——它不是某个具体游戏的成品,而是一套把窗口、绘制、输入、计时、资源、状态管理这些公共骨架提前搭好的启动代码,你拿到手只要往里塞玩法逻辑就行。它适合三类人:完全没接触过图形编程、想找个能跑的最小例子的新手;写过一两个demo但每次都要重抄一遍框架的人;以及需要给一批学生统一作业结构、避免每个人从零踩坑的指导者。我用这套结构前后改过七八个小项目,从打砖块到横版跳跃都套得进去,下面把里面的取舍、坑和关键代码一层层拆开讲。

1. 模板到底该做成什么形态

1.1 我不建议新手直接从引擎开始

很多人一上手就想用成熟的游戏引擎,觉得那样“省事”。实际带过几轮之后我的看法反过来了:对于想搞清楚“一帧画面究竟是怎么被画出来的”这件事的人来说,从图形库起步的价值远大于从引擎起步。引擎把渲染循环、资源管线、场景树全封装好了,你调用一个接口就出画面,爽是爽,但一旦出现画面撕裂、物体抖动、输入延迟这类问题,你连从哪一层开始查都不知道。

EasyX 的定位刚好卡在中间:它把窗口创建、GDI 绘图、消息循环这些底层细节包了一层薄壳,提供initgraph、putimage、fillcircle这类直白接口,但帧率、时间步长、对象更新、资源缓存这些事它一概不管,仍然得你自己设计。这个“管一半”的特性对学习特别友好——你既能看清一帧的完整生命周期,又不用去啃 Win32 那一堆WNDCLASS、RegisterClass、CreateWindowEx。

模板的意义就在这儿:把那些“每次都要写、写了又不长记性”的样板代码固定下来,把真正的精力留给玩法。我见过太多人卡在双缓冲没开导致画面闪成频闪灯,或者Sleep(16)写死导致不同机器速度天差地别,这些都不属于游戏设计问题,纯粹是模板没搭好。

1.2 模板必须一次性解决的五件事

我把历年踩过的坑归了个类,一个合格的 C++EasyX 小游戏模板至少要覆盖五个方面,少一个后面都会反噬:

第一是绘制稳定性。不开批量绘制,画面会疯狂闪烁,尤其是物体多的时候。第二是时间一致性。用固定Sleep控制速度,配置高一点和低一点的机器跑出来完全两个游戏。第三是输入可靠性。直接读当前按键状态做跳跃判断,会导致按住不放连续起跳。第四是资源生命周期。图片反复loadimage会拖慢启动,退出时不释放又容易被检测工具报警。第五是窗口关闭与退出。很多人写完发现点红叉之后控制台还在跑,或者直接卡死。

这五件事的共同点是:它们和具体玩法完全无关,但每一个处理不好都能让你怀疑自己不会写代码。模板的价值就是把这五个问题一次性封死在底层,之后无论你做弹球、贪吃蛇还是飞机大战,都不用再想第二遍。

1.3 一个容易被忽略的设计前提

还有个前提得先说明白:模板不应该做成“万能框架”。我早期版本试图把物理、动画、UI 全塞进去,结果代码膨胀到三千多行,自己回头看都要查半天。后来砍到只剩必要的六七个模块,反而好用了。

判断标准很简单:这段代码是否每个游戏都要写。窗口初始化,要;双缓冲,要;帧循环,要;输入状态表,要;对象基类,大概率要;音效封装,看情况但要;物理积分、寻路、粒子系统,不要。按这条线砍完,模板主体控制在七八百行,新人一天能读完,这就对了。

2. 目录结构与模块职责划分

2.1 一个能撑住三次迭代的目录长什么样

我见过最典型的失败布局是把所有东西都堆在main.cpp里,第二周想加个敌人类,发现整个文件已经从 300 行涨到 1200 行,改一行要滚半天。所以模板第一件事是分文件,按职责切:

project/ ├── main.cpp 程序入口、主循环 ├── core/ │ ├── GameApp.h/.cpp 应用外壳:初始化、销毁、状态切换 │ ├── Clock.h/.cpp 高精度计时与固定步长 │ └── Input.h/.cpp 键鼠状态表与边沿检测 ├── gfx/ │ ├── Renderer.h/.cpp 批量绘制封装、文字绘制、坐标转换 │ └── Resources.h/.cpp 图片与音效的加载缓存 ├── game/ │ ├── Object.h/.cpp 游戏对象基类与对象池 │ ├── Player.h/.cpp 具体玩家实现(示例) │ └── World.h/.cpp 场景容器:更新、碰撞、绘制 └── assets/ ├── img/ └── snd/

这个布局的关键不在好不好看,而在于依赖方向单一。game层依赖gfx和core,gfx依赖core,core谁也不依赖。方向一旦固定,你单独替换渲染模块或者单独给计时器写单元测试都不会牵一发动全身。我试过把Renderer整个换成基于GetImageBuffer的像素级绘制,game层一行没改,这就是分层带来的收益。

2.2 各模块之间到底怎么串

理解模块划分最好的方式是顺着一次主循环走一遍。程序启动时GameApp::init()里依次做四件事:调initgraph开窗口、Renderer初始化字体和背景色、Resources预加载资源、World创建初始对象。然后进入GameApp::run()里的 while 循环,每一轮循环的顺序是固定的:

  1. Clock::tick()拿到真实经过时间,累加到累加器;
  2. Input::update()把这一帧的消息队列清空,更新按键状态表;
  3. 当累加器够一个固定步长时,执行一次World::update(dt),并扣掉累加器;
  4. Renderer::beginFrame()清屏,World::draw()画所有对象,Renderer::endFrame()提交;
  5. 视剩余时间决定要不要短暂休眠。

这个顺序里有两个地方新手最容易搞反。一是输入必须在更新之前,否则这一帧用的是上一帧的按键数据,手感上能差出小半帧。二是清屏必须在绘制之前,而且清屏要清在批量绘制的缓冲里,不是直接清屏幕,这两者混了会看到奇怪的拖影。

提示:World::update和World::draw必须分开。我见过把绘制写在更新里的代码,一旦要做“更新频率低于渲染频率”的插值就彻底没法改,只能推倒重来。

2.3 为什么状态机要放在应用层而不是场景层

菜单、游戏中、暂停、结算这几个界面,本质上是同一套循环里不同的更新与绘制逻辑。我把它放在GameApp这一层,用一个enum class GameState加一个switch就够了。有人喜欢做一套完整的场景栈,支持压栈弹栈,那当然更灵活,但对小游戏来说是过度设计——你最多就是“游戏中按下 ESC 进入暂停,再按回到游戏中”,两层状态,栈结构带来的复杂度远超收益。

我的处理方式是给World留一个pause()和resume(),暂停时主循环照常跑、照常画,只是不去调World::update。这样暂停界面的动画(比如闪烁的提示文字)还能继续动,视觉上比“整个画面冻住”舒服得多。这个细节很少有教程提,但你做出来之后体验差别很明显。

3. 窗口、双缓冲与消息处理

3.1 initgraph 的参数别凭感觉填

窗口初始化很多人抄一行就完事,其实参数背后有讲究。基本形式是initgraph(宽, 高, 标志位),宽高按你想要的逻辑分辨率给,我的习惯是 960×640 或者 800×600,别一上来就 1920×1080——窗口太大在小屏幕上会被系统压缩,画面直接糊掉。标志位里最常用的几个:

标志作用什么场景需要
控制台显示标志保留黑色控制台窗口调试打日志阶段
双击响应标志让窗口接收双击消息几乎用不到
禁止最小化标志去掉最小化按钮全屏类固定窗口
双缓冲窗口标志窗口层自带缓冲与手动批量绘制二选一即可

我的做法是:调试期带上控制台标志,printf打帧率和对象数量;发布前去掉。双缓冲窗口标志和BeginBatchDraw别叠着用,虽然多半不出错,但两者叠加时偶发过刷新不同步的情况,用一套就够。

坐标原点在窗口左上角,x 向右、y 向下,这跟数学课上的直角坐标系是反的。这不是 EasyX 的锅,几乎所有二维图形接口都这样。我建议模板里直接提供一个转换函数:

inline int toScreenY(float worldY, int worldHeight) { return worldHeight - static_cast<int>(worldY); }

前期用屏幕坐标直写没问题,但一旦你要做“向上为正”的物理计算(比如重力加速度写成 -9.8),就一定要有个转换层,否则每次写代码都在脑子里翻一次符号,迟早翻错。

3.2 批量绘制为什么能干掉闪烁

闪烁的根源是“画到一半被看到”。不开批量绘制时,一次cleardevice加上几十次putimage,每一次都是在向屏幕提交内容,屏幕刷新恰好发生在中间,你看到的就是半成品画面。

批量绘制的本质是把这些绘制指令先攒到一个内存缓冲区里,等你调FlushBatchDraw时再一次性提交给屏幕。代码上就是三件套:

BeginBatchDraw(); // 进入批量模式 while (is_run()) { cleardevice(); // 清的是缓冲区 // ... 一堆绘制 ... FlushBatchDraw(); // 一次性提交 } EndBatchDraw(); // 退出批量模式

这里有个坑要提醒:cleardevice()清的是当前绘图目标,在批量模式下就是那个缓冲区,所以必须放在BeginBatchDraw之后、绘制之前。我见过放在循环外面的写法,结果第二帧开始所有旧内容叠加,屏幕上越画越花。

还有个更隐蔽的问题:如果你在批量绘制期间调用了某些“强制刷新”的接口(比如模态的输入框),画面会突然闪一下。这不是 bug,是那个接口绕过了缓冲直接画屏幕。解决办法是把这类调用挪到FlushBatchDraw之后。

3.3 自己写消息处理,别指望现成的

EasyX 提供peekmessage来取消息,返回true表示队列里还有。它的正确用法是在每帧开头把队列清空,而不是只取一条:

ExMessage msg; while (peekmessage(&msg, EM_MOUSE | EM_KEY)) { if (msg.message == WM_KEYDOWN) { input.setKeyDown(msg.vkcode); } else if (msg.message == WM_KEYUP) { input.setKeyUp(msg.vkcode); } else if (msg.message == WM_LBUTTONDOWN) { input.setMouseDown(msg.x, msg.y); } }

用while而不是if,是因为一帧内可能积压了十几个消息(尤其是快速连按),只取一条会导致丢键。这个现象在低帧率时特别明显,玩家按了三下跳跃,游戏只认一次。

窗口关掉之后的处理要分情况。较新的 EasyX 提供is_run()直接判断绘图窗口是否还存在,写while (is_run())最省事。如果你用的版本没有这个函数,就用GetHWnd()拿到窗口句柄再调系统的IsWindow()判断,效果一样。

注意:不要在消息循环里直接执行业务逻辑。我见过在WM_KEYDOWN分支里直接改玩家坐标的写法,帧率一高玩家就像瞬移。消息只能用来更新“状态表”,真正的逻辑判断统统放到update里统一处理。

4. 帧率控制与时间步长

4.1 写死 Sleep(16) 到底错在哪

这是新手代码里出现频率最高的一行:循环末尾加一句Sleep(16),心想十六毫秒四舍五入就是六十帧。问题有三层。

第一层,Sleep的实际休眠时间不精确,系统调度粒度通常是十到十五毫秒,你写 16 实际可能睡 16 到 31 毫秒不等,帧率在 30 到 60 之间波动。第二层,你把“帧”当成了时间单位,那么所有速度参数都是“每帧多少像素”,帧率一变,游戏速度跟着变——同一份代码在两种机器上跑出两种手感。第三层,卡顿发生时(比如加载资源),帧时间突然变成 200 毫秒,如果按帧算位移,物体会瞬间穿模飞出屏幕。

正确的做法是把更新频率和渲染频率解耦,逻辑永远按固定时间步长推进。这样物理计算、碰撞判定都是确定性的,同样的操作序列在哪儿跑结果都一样。

4.2 累加器加固定步长的落地写法

核心思路一句话:用真实经过的时间往“时间银行”里存钱,每次取固定面额出来消费,取不出来就先不更新。

const double FIXED_DT = 1.0 / 60.0; // 逻辑每秒推进60步 const double MAX_FRAME = 0.25; // 单帧最大补偿,防止卡顿后疯狂追帧 double accumulator = 0.0; Clock clock; while (is_run()) { double frameTime = clock.tick(); // 本次循环真实耗时 if (frameTime > MAX_FRAME) frameTime = MAX_FRAME; accumulator += frameTime; input.update(); int steps = 0; while (accumulator >= FIXED_DT && steps < 5) { world.update(FIXED_DT); accumulator -= FIXED_DT; ++steps; } renderer.beginFrame(); world.draw(); renderer.endFrame(); }

这里的MAX_FRAME和steps < 5是两个保护阀。第一个防止你切出窗口再切回来时,累加器攒了几秒钟的量,一回来物体直接飞出天外;第二个防止单帧内无限追帧把 CPU 吃满。经验值是最大补偿 0.25 秒、单帧最多追五步,超过就丢弃,宁可让游戏慢一点点也不要卡死。

时钟必须用高精度计时器。用系统的毫秒级计时函数做累加器,精度只有十几毫秒,累加误差会累积成肉眼可见的抖动。标准写法:

class Clock { public: Clock() { QueryPerformanceFrequency(&freq_); QueryPerformanceCounter(&last_); } double tick() { LARGE_INTEGER now; QueryPerformanceCounter(&now); double dt = double(now.QuadPart - last_.QuadPart) / double(freq_.QuadPart); last_ = now; return dt; } private: LARGE_INTEGER freq_{}, last_{}; };

渲染如果想更顺滑,可以在两次逻辑更新之间做插值:记录上一帧和当前帧的位置,按accumulator / FIXED_DT的比例混合。六十步逻辑配六十帧渲染时用不上,但如果你把逻辑降到三十步,插值能让画面看起来仍然是六十帧的流畅度。这套东西实现起来三十行左右,等真的需要再做也不迟。

4.3 剩余的睡眠时间怎么处理

固定步长跑完之后通常还有余量,直接空转会把 CPU 占用拉到百分之百,风扇呼呼响。我的做法是算一下离下一帧还有多久,超过两毫秒就休眠一毫秒,剩下的用主动让出时间片顶掉:

double remain = FIXED_DT - accumulator; if (remain > 0.002) { Sleep(1); }

注意这里休眠的时间不能按remain原样给,因为Sleep不精确,睡多了会导致下一帧时间不够、帧率掉下来。一毫秒一毫秒地让,虽然多几次系统调用,但帧率稳得多。我实测同一个小游戏,这种写法 CPU 占用在百分之三到五,直接空转是百分之二十五往上。

5. 输入系统:状态表比你想的重要

5.1 三层状态:按住、刚按下、刚松开

判断“跳跃”这个动作,需要的是“这一帧刚按下”,不是“当前按住”。这两个条件差之毫厘谬以千里:用按住判断,玩家按住空格不放,角色每秒起跳六十次。

所以输入模块至少要维护三张表:down(当前按住的键)、pressed(本帧内新按下的键)、released(本帧内新松开的键)。处理消息时同时更新down和pressed,每帧末尾清掉pressed和released。用一个 256 长的布尔数组按虚拟键码索引就够,内存占用可以忽略。

class Input { public: void onKeyDown(int vk) { if (!down_[vk]) pressed_[vk] = true; down_[vk] = true; } void onKeyUp(int vk) { down_[vk] = false; released_[vk] = true; } bool isDown(int vk) const { return down_[vk]; } bool isPressed(int vk) const { return pressed_[vk]; } bool isReleased(int vk) const { return released_[vk]; } void endFrame() { std::fill(std::begin(pressed_), std::end(pressed_), false); std::fill(std::begin(released_), std::end(released_), false); } private: bool down_[256]{}, pressed_[256]{}, released_[256]{}; };

onKeyDown里那句if (!down_[vk])是关键,它挡住了系统的按键重复消息。按住一个键时系统会周期性地补发按下消息,不挡的话pressed会在按住期间反复为真,效果和直接用按住判断一样糟。

5.2 为什么不推荐直接读系统按键状态

系统提供的那种“查询当前某个键是否按下”的接口,用起来确实方便,一行就能判断。但它有两个毛病:一是绕过了消息队列,和你的帧循环不同步,可能出现“这一帧判断为按下,但消息还没处理”的错位;二是它查询的是全局状态,窗口失去焦点时依然能读到你按住的方向键,玩家切出去聊了会天回来,发现角色一直在往右跑。

消息驱动的方案天然解决了这两个问题——窗口没焦点就收不到键消息,状态表自然全为假。唯一的代价是某些特殊键(比如功能键区的组合)可能不走消息,但小游戏用不到那些。

鼠标处理同理,ExMessage里的x、y已经是窗口客户区坐标,不用再做转换。需要判断点击是否落在某个矩形里,写个简单的范围判断即可:

inline bool hitRect(int mx, int my, int x, int y, int w, int h) { return mx >= x && mx < x + w && my >= y && my < y + h; }

6. 游戏对象抽象与碰撞检测

6.1 基类只放四样东西

对象基类的设计特别容易失控。我见过往里塞了二十多个虚函数的版本,结果每个具体类都要实现一堆用不上的空方法。砍到最小的接口其实只有四样:位置、尺寸、一个更新方法、一个绘制方法。

struct Object { float x = 0, y = 0; float w = 0, h = 0; bool alive = true; virtual ~Object() = default; virtual void update(float dt) = 0; virtual void draw() = 0; virtual void reset() {} };

alive标记配合后面说的对象池,能省掉大量delete的时机纠结。reset是可选的,用于对象复用时重置内部状态,比如血条、动画帧计数。

包围盒w、h用浮点存,是因为运动积分算出来的位置本来就是浮点,转成整数会累积舍入误差,慢速移动时表现为“抖动”。绘制那一刻再转成整数坐标。

6.2 对象池比你想的简单

子弹、敌机这类高频创建销毁的对象,反复new和delete有两个问题:一是堆分配有开销,二是频繁分配释放会让内存碎片化。对象池就是一次性分配一批,用alive标记重复利用。

template <typename T, size_t N> class Pool { public: T* acquire() { for (auto& obj : items_) { if (!obj.alive) { obj.alive = true; obj.reset(); return &obj; } } return nullptr; // 池满了 } void updateAll(float dt) { for (auto& obj : items_) if (obj.alive) obj.update(dt); } void drawAll() { for (auto& obj : items_) if (obj.alive) obj.draw(); } private: std::array<T, N> items_{}; };

用定长数组而不是vector,是因为小游戏的同类对象数量基本可预估,子弹给两百个、敌机给五十个,池满时直接不生成新对象,游戏逻辑上完全可以接受,还能顺带避免某些极端情况下的雪崩。

遍历顺序上有个细节:updateAll里如果某个对象在更新过程中把自己的alive置为假,后续对象不受影响,因为它只是跳过。但如果你在遍历时用基于范围的for去删除元素就会翻车,这也是定长数组的好处之一——只改标记,不改容器结构。

6.3 碰撞检测够用就行

小游戏里九成情况只需要两种形状:轴对齐矩形和圆。矩形对矩形:

inline bool aabb(float ax, float ay, float aw, float ah, float bx, float by, float bw, float bh) { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; }

圆对圆就一句距离平方比较,注意用平方省掉开方:

inline bool circleHit(float ax, float ay, float ar, float bx, float by, float br) { float dx = ax - bx, dy = ay - by; float r = ar + br; return dx * dx + dy * dy <= r * r; }

要注意的是坐标系方向。因为屏幕 y 轴向下,如果你的游戏逻辑里 y 轴向上(比如跳跃物理),在 AABB 判断时必须统一到同一套坐标里,否则会出现“明明没碰上却判定碰撞”的诡异现象。我的习惯是逻辑层统一用屏幕坐标(y 向下),重力加速度写成正值,跳跃初速度写成负值,这样所有几何判断都能直接用,缺点是物理公式看着别扭,习惯就好。

对象数量上去之后(超过几百),暴力两两检测会明显掉帧。简单优化是空间分格:把屏幕切成固定大小的格子,每帧把对象按中心点塞进对应格子,只和同格及相邻格的对象做检测。这招实现起来五十行以内,几百个对象时提速肉眼可见。不过大部分小游戏压根到不了这个量级,先暴力跑着,掉帧了再优化。

7. 资源加载与音效封装

7.1 图片加载与透明贴图的两条路

图片加载本身很简单,一个loadimage搞定,但路径问题能折腾掉半天。用相对路径时,工程的工作目录和你想象的不一定一致——在编辑器里跑和在资源管理器里双击 exe 跑,工作目录可能差好几个层级。稳妥做法是启动时先把工作目录设置到 exe 所在目录,代码里一律用相对路径,这样在哪跑都一样。

透明贴图有两条路。第一条是掩码图法:准备一张原图和一张黑白掩码图,先用按位与的方式画掩码,再用按位或的方式画原图,两次操作之后只有原图里非掩码的部分被保留。第二条是直接加载带透明通道的图片,加载后正常绘制,库内部会处理透明度。

IMAGE img, mask; loadimage(&img, _T("assets/img/player.png")); loadimage(&mask, _T("assets/img/player_mask.png")); putimage(x, y, &mask, SRCAND); // 先挖洞 putimage(x, y, &img, SRCPAINT); // 再填补

掩码图法的优点是兼容性最好,什么版本都能用;缺点是美术成本翻倍,每张图都要配一张掩码。带透明通道的方案更省事,但要注意图片必须是真正的透明格式,有些工具导出时会把你以为透明的地方填成白色。

提示:不管用哪种方案,都建议在Resources模块里做一层缓存。用字符串加映射表存已加载的图片,加载前先查表。我见过在绘制函数里调loadimage的代码,每帧读几十次磁盘,帧率能掉到个位数。

7.2 用系统多媒体接口放音效

不用引第三方音频库,直接用系统自带的多媒体控制接口就行。它的用法是发字符串指令,先打开再播放:

#pragma comment(lib, "winmm.lib") void playBGM(const wchar_t* path) { mciSendString(L"close bgm", nullptr, 0, nullptr); std::wstring cmd = L"open \""; cmd += path; cmd += L"\" alias bgm"; mciSendString(cmd.c_str(), nullptr, 0, nullptr); mciSendString(L"play bgm repeat", nullptr, 0, nullptr); } void playSFX(const wchar_t* path) { mciSendString(L"close sfx", nullptr, 0, nullptr); std::wstring cmd = L"open \""; cmd += path; cmd += L"\" alias sfx"; mciSendString(cmd.c_str(), nullptr, 0, nullptr); mciSendString(L"play sfx", nullptr, 0, nullptr); }

用别名(alias bgm)来管理,是因为这个接口靠别名定位资源,不能直接对着路径重复播放。每次播放前先关闭旧的,避免叠音。背景音乐加repeat参数自动循环,音效不加。

这里有个坑:音效播放是异步的,如果你用一个别名播所有音效,前一个还没放完就被关闭,听起来像音效被截断。做法是给几路音效各留一个别名,比如sfx1到sfx4,轮流使用,或者干脆每个音效文件一个别名。小游戏音效数量不多,后者更省心。

另外要记住退出前关掉所有别名,否则进程结束后音频设备可能还占着,下次启动偶尔没声音。在GameApp::shutdown()里挨个close一遍就行。

8. 常见问题排查实录

8.1 画面类问题的排查路径

画面问题最典型的三种表现:闪、糊、抖。闪基本是没开批量绘制,或者开了但FlushBatchDraw的位置不对。糊通常是缩放导致的——窗口尺寸和你绘制时用的坐标不是同一套,图片被拉伸。抖则多半是坐标从浮点转整数时的手段有问题,static_cast<int>是直接截断,对于负坐标会向零取整,导致物体穿过原点时抖一下,用std::lround四舍五入能缓解。

还有一种“幽灵拖影”:物体移动后旧位置还留着一层淡影。检查一下是不是用了带缓存的绘制手段但没有正确清屏,或者在某些绘制接口里传了按位或的合成模式而本意是覆盖。

8.2 性能类问题的定位方法

卡顿先分清是逻辑卡还是渲染卡。方法很简单:在循环里分别记录update阶段总耗时和draw阶段总耗时,打到控制台上看哪个大。如果是update大,多半是碰撞检测的复杂度上去了,或者某个循环里干了重活;如果是draw大,通常是绘制调用次数太多,或者每帧都在做图片加载、字体创建这类一次性工作。

字体创建是个高频雷区。settextstyle每次调用都会去匹配字体,一帧调几十次开销不小。正确做法是启动时设好不同字号,绘制时只调outtextxy。如果确实要用多种字号,把它们封装成常量,初始化阶段全部设一遍,之后靠切换文本颜色而不是重建字体来区分样式。

CPU 占用高但帧率正常,基本就是没做休眠,一直在空转。按前面说的一毫秒一让,能压到很低。

8.3 资源与编码类问题速查

中文乱码是绕不过去的槛。根源在字符集:工程用多字节字符集时,字符串字面量按本地编码存;用 Unicode 时按宽字符存。图形接口的文本绘制函数对这两者的接受方式不同,混用就出方块。

现象大概率原因处理方式
中文显示成方块或问号工程字符集与字符串字面量类型不匹配统一工程字符集设成 Unicode,所有含中文的字面量加宽字符前缀
图片加载失败但路径没错工作目录不是 exe 所在目录启动时设置工作目录到可执行文件目录
反复启动后偶发无声音音频别名未关闭退出前逐个关闭别名
运行中内存持续上涨图片或对象在循环里反复创建改为启动时一次性加载,循环内复用
点关闭按钮后程序不退主循环条件没检查窗口存活循环条件换成窗口存活判断
启动就闪退无提示未初始化图形窗口就调绘制接口确认初始化顺序,绘制前必有窗口

还有一条排查技巧值得记下来:程序在某一步之后就没有任何反应,多半是那次绘制或加载发生了异常但被吞掉了。临时把工程字符集切到带调试信息的模式,或者在关键调用前后加printf打点,能快速定位到具体是哪一句出的问题。

9. 从模板到一个真正能玩的示例

9.1 最小可运行骨架

把前面的东西串起来,一个能跑起来看到方块移动的完整骨架大概是这样:

#include <graphics.h> #include <windows.h> #include <array> const int W = 960, H = 640; const double FIXED_DT = 1.0 / 60.0; struct Player { float x = W * 0.5f, y = H * 0.5f; float vx = 0, vy = 0; float speed = 320.0f; int size = 40; void update(float dt, bool left, bool right, bool up, bool down) { vx = (right - left) * speed; vy = (down - up) * speed; x += vx * dt; y += vy * dt; if (x < 0) x = 0; if (y < 0) y = 0; if (x > W - size) x = W - size; if (y > H - size) y = H - size; } void draw() const { setfillcolor(RGB(80, 180, 255)); solidrectangle((int)x, (int)y, (int)x + size, (int)y + size); } }; int main() { initgraph(W, H); BeginBatchDraw(); setbkcolor(RGB(24, 26, 32)); settextcolor(WHITE); Player player; bool down[256] = {false}; LARGE_INTEGER freq, last; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&last); double acc = 0; while (is_run()) { LARGE_INTEGER now; QueryPerformanceCounter(&now); double dt = double(now.QuadPart - last.QuadPart) / double(freq.QuadPart); last = now; if (dt > 0.25) dt = 0.25; acc += dt; ExMessage msg; while (peekmessage(&msg, EM_KEY)) { if (msg.message == WM_KEYDOWN) down[msg.vkcode] = true; if (msg.message == WM_KEYUP) down[msg.vkcode] = false; } while (acc >= FIXED_DT) { player.update(FIXED_DT, down[VK_LEFT], down[VK_RIGHT], down[VK_UP], down[VK_DOWN]); acc -= FIXED_DT; } cleardevice(); player.draw(); FlushBatchDraw(); } EndBatchDraw(); closegraph(); return 0; }

这段代码去掉注释不到六十行,但已经包含了双缓冲、高精度计时、固定步长、消息清空、边界钳制这几个要素。你可以直接在它上面加敌人、加子弹、加计分。我每次开新项目就是从这段开始改,改到后来都形成了肌肉记忆。

9.2 后续可以往上加什么

骨架跑通之后,往上加的路径我一般按这个顺序走:先加对象池和碰撞,让游戏有可交互的实体;再加资源管理,把方块换成图片;然后加状态机,做出开始界面和结算界面;最后加音效和存档。

存档有个小技巧值得单独说:别用二进制直接 dump 内存结构,改个字段就废了。用纯文本的键值对存,一行为一个字段,读的时候逐行解析。好处是调试时能直接用文本编辑器改数值,坏处是需要自己写几个字符串切割函数——但那也就二三十行的事,比维护版本兼容性省心得多。

再往后如果对象数量真的上去了,可以试试前面提过的空间分格;如果画面元素复杂了,可以试试把绘制按图层排序,背景层不参与每帧重绘而是只画一次到一张大图上再贴回来。这些都是按需加,不用一开始就设计进去。

我在实际使用这套模板的过程中,最大的体会是:模板的价值不在于功能多,而在于把那些“不写会出事、写了又没成就感”的东西一次性处理掉。每次开新项目,光是省掉窗口闪烁、帧率不稳、按键连发这三个坑的排查时间,就够我把玩法磨两轮了。真正决定游戏好不好玩的,还是你往update里填的逻辑,以及那一点点手感上的微调——比如跳跃时的重力曲线、按键响应的那一两帧提前量,这些模板帮不了你,只能自己一遍遍试。

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

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

立即咨询