第一卷把窗口和主循环跑通之后我一度以为基于 EGE 做一个横板卷轴射击游戏剩下的事情就是画一架飞机、按空格发射子弹。真正把背景滚起来的第一天问题全冒出来了背景每隔几秒整个画面跳一个像素敌机飞到屏幕边缘会突然顿一下子弹明明穿过了敌机却毫无反应快速连发几轮之后帧率开始肉眼可见地掉。这些毛病在第一卷的静态画面里一个都看不出来一旦画面开始横向移动所有隐藏的坐标问题、时间问题、内存问题就同时暴露。这篇是系列的第二卷只讲一件事把静态的绘图程序改造成一个能持续横向滚动的射击游戏骨架。里面不会重复窗口创建、基础图形绘制这些内容重点落在相机与世界坐标、视差滚动、操作手感、对象池、碰撞检测、主循环时间步长这几块。读者对象是已经把 EGE 的initgraph跑通、知道putimage怎么用的朋友如果你还在纠结怎么让窗口不闪那建议先回头把第一卷的批量绘制部分补一下再来看。1. 让背景滚起来的正确姿势世界坐标与屏幕坐标分家1.1 第一版为什么抖每个物件自己算偏移我最早写的一版卷轴非常直白背景图有若干个切片每帧把每个切片的 x 坐标减掉一个速度值减到屏幕左边之外就把它挪回右边。写完跑起来静态看没问题一旦玩家开始移动就开始出洋相。原因在于我把整个世界在移动和某个东西自己在移动混为一谈了。背景切片、敌机、子弹、飘落的碎片每一个都自己维护一套位置和速度那它们的相对关系就没法统一。更麻烦的是玩家飞机通常要保持水平位置大致固定视差层又要比前景滚得慢这时候谁减多少就变成一堆散落在各个模块里的魔法数字改一个背景速度要翻五个文件。正确的做法是所有实体统一存放在世界坐标里世界坐标只有一套永不改变语义就是在这个关卡地图上的绝对位置。相机是唯一的观察者它有自己的位置。渲染的时候屏幕坐标 世界坐标 - 相机位置。相机往右走整个世界看起来就往左滚但每个实体的世界坐标一动不动。这个拆分带来的好处不只是整洁。屏幕上的 HUD、血条、分数不需要跟着滚它们用屏幕坐标画就行想做子弹时间或者震屏只需要动相机的偏移量想做回放或者录像把每帧的世界坐标存下来就行跟渲染完全解耦。1.2 相机对象与两个换算函数相机结构我写得极简就三个字段struct Camera { double x 0.0; // 相机左边界在世界坐标里的位置 double y 0.0; int shakeX 0; // 震屏偏移单独放和位置解耦 int shakeY 0; }; inline int worldToScreenX(double wx, const Camera cam) { return (int)std::lround(wx - cam.x) cam.shakeX; } inline int worldToScreenY(double wy, const Camera cam) { return (int)std::lround(wy - cam.y) cam.shakeY; } inline double screenToWorldX(int sx, const Camera cam) { return (double)(sx - cam.shakeX) cam.x; }这四个函数是整个引擎里被调用次数最多的代码写错一个符号后面全乱。screenToWorldX看着平时用不上但做鼠标瞄准、做点击屏幕某处生成爆炸这类功能时必须要有反向换算一旦缺失你就只能在渲染循环里到处埋临时变量。std::lround而不是(int)强转这点很关键。C 的(int)是向零截断负坐标会往 0 的方向靠结果就是相机经过原点附近时屏幕上的物体出现一个方向相反的单像素跳变。这个问题我在调试时盯了快一个小时才反应过来因为跳变只在相机 x 穿过 0 的瞬间发生一次正常测试根本碰不到。还有一个容易忽略的点世界坐标用double不要用int。用整数坐标的话慢速移动比如每秒 20 像素在 60 帧下每帧只走 0.33 像素全部被截断成 0物体看起来是卡住然后突然跳一下。浮点存位置、渲染时取整这是唯一稳妥的写法。1.3 相机取整一次全层共用上面那个worldToScreenX是概念版本真正跑的时候不能每个物体都独立调一次。为什么假设相机位置是 100.4A 物体的世界坐标是 200.4B 是 200.6。独立取整的话A 屏幕位置是lround(100.0)等于 100B 是lround(100.2)等于 100看起来一样。但如果相机是 100.6 呢A 变成lround(99.8)等于 100B 变成lround(100.0)等于 100。一旦两者的取整方向不同视觉上这一帧的间距就比上一帧多了 1 像素形式就是相对间距在抖。解决办法是在每帧渲染开始前先把相机偏移算成一个整数同一个视差层里的所有元素都用这个整数int camOffsetX (int)std::floor(cam.x); // 层内每个元素 int sx (int)std::lround(wx) - camOffsetX;这里用floor而不是lround是因为负坐标下floor行为一致lround(-0.5)是 -1floor(-0.5)也是 -1但lround(0.5)是 1 而floor(0.5)是 0边界行为更可预测。至于相机只取整一次全层共用实践中我把它做成了渲染上下文的一个字段所有绘制函数都从这里拿偏移不再各自算。提示不同视差层之间的偏移量必然不同因为层速不同因此全层共用指的是同一层内部共用层与层之间各自算各自的整数偏移这是没问题的因为层与层之间本来就有相对运动。1.4 相机跟随的前瞻与边界钳制相机直接等于玩家 x 是最省事的写法也是最难看的写法。玩家居中意味着屏幕右半部分永远是你还没看到但即将撞上的区域射击游戏节奏一快玩家根本来不及反应。我给相机加了一个固定的前瞻偏移让玩家在屏幕偏左三分之一的位置右边留出足够的视野const double LOOKAHEAD 160.0; double targetX player.x - LOOKAHEAD; cam.x (targetX - cam.x) * 0.12; // 平滑跟随避免硬切那个 0.12 是每帧的插值系数和帧率相关如果想做成帧率无关的写法用1 - exp(-k * dt)更规范。这个系数太大会让相机僵跟玩家急停时画面跟着硬停观感很硬太小则会拖尾玩家加速时视野跟不上容易撞机。我最后定在 0.1 到 0.15 之间具体数值跟最大移动速度挂钩。边界钳制是另一件必须做的事。关卡有起点也有终点相机到了左边界就不能再往左走否则玩家会看到地图外面的空白。写法很简单if (cam.x 0) cam.x 0; if (cam.x levelWidth - screenW) cam.x levelWidth - screenW;但要注意当关卡宽度小于屏幕宽度时这两个条件会互相打架levelWidth - screenW变成负数相机被钳制到负数又立刻被钳制回 0来回抖。加一个if (levelWidth screenW)的前置判断就能避开。这种边界情况在关卡编辑器里做一张特别短的测试图就能暴露出来比在正式关卡里瞎找强得多。2. 视差分层层速比、负数取模与贴图接缝2.1 层速比不是拍脑袋定的视差滚动的原理谁都懂远处的山比近处的草滚得慢。但具体慢多少是有一套经验区间的。层速指的是该层的滚动位移相对于相机位移的倍数系数越小表示越远。| 层名 | 速比系数 | 典型内容 | 说明 | | 远景天幕 | 0.05 - 0.12 | 云层、星云、远山 | 几乎不动只提供氛围 | | 中远景 | 0.20 - 0.30 | 大型山体、城市天际线 | 有轻微位移感 | | 中景 | 0.45 - 0.55 | 建筑群、树林 | 明显可感知 | | 近景地面 | 1.00 | 地面、跑道、管道 | 与游戏逻辑层同步 | | 前景装饰 | 1.20 - 1.50 | 飞过的碎石、前景草丛 | 比逻辑层更快制造纵深 |超过 1.6 的系数会让前景明显抢戏玩家视线被吸引到无意义的装饰上反而看不清敌机。低于 0.05 的层基本等于静止做了也白做不如直接画一张不滚的静态图省性能。系数定好之后每个层的偏移量就是cam.x * factor。注意这里是浮点乘法层偏移也是浮点渲染时再取整。千万别把系数写成1/3这种分数形式直接算浮点累加误差会让某几帧的偏移比相邻帧少 1 像素形成周期性的细微抖动肉眼在纯色块背景上看不出来在有细密纹理的背景上会变成一条条移动的暗纹。2.2 取模的负号坑无限循环的横向背景标准做法是对偏移量取模。而 C 的%和fmod在处理负数时返回负数这是无数人栽过的地方。int start (int)std::fmod(cam.x * factor, (double)tileW); // 相机往左走cam.x 为负时start 也是负数 // 我们要的是 0 start tileW if (start 0) start - tileW; // 让它落在一个 tile 宽度之外我第一版就是直接拿负数start去循环画图结果相机一往左走背景右侧就露出一条空白。因为循环条件x screenW在起点为负的时候起始位置太靠左右侧画不满。把起点推到-tileW到 0 之间就对了这样第一个切片一定从屏幕左边缘之外开始最后一片一定覆盖到屏幕右边缘之外。for (int x start; x SCREEN_W; x tileW) { putimage(x, groundY, img_ground); }顺带一提这个循环里start和x都是整数cam.x * factor是浮点取整发生在fmod之后。如果反过来先取整再取模浮点的亚像素信息就彻底丢了滚动的平滑度会明显下降尤其是低倍速的远景层因为它的偏移量变化本来就慢取整后可能连续好几帧都是同一个值看起来像卡顿。2.3 接缝、色带与切片尺寸背景切片拼接最常见的两个视觉问题一条细细的缝或者边界处颜色对不上。缝隙的成因通常是切片边缘有 1 像素的半透明或者描边。图片编辑器导出带透明通道的 PNG 时边缘像素的 alpha 是渐变的putimage用普通混合模式贴上去两个相邻切片各自的半透明边缘叠加就出现比周围亮或暗的一条线。处理办法要么是在导出时让切片边缘完全不透明且颜色一致要么干脆让相邻切片重叠 1 到 2 像素用后画的盖住前一个的边缘。色带的成因是缩放。如果原始贴图是 1.5 倍缩放绘制EGE 的putimage并不做图像缩放得靠resize函数或者自己预先处理好尺寸。而任何缩放都会带来插值插值过的像素再拼接接缝处必然出现过渡带。所以我定的规矩是所有滚动背景的切片按最终显示尺寸原样导出代码里一律不做缩放。内存多吃一点但省掉了整类问题。切片宽度我喜欢用 256 或 512 这类整齐的数字倒不是为了什么对齐纯粹是让fmod的除法结果稳定也方便手工核对第几片在哪个位置。非 2 的幂的宽度比如 300也能正常工作只是调试时心算麻烦些。2.4 贴图资源的一次性装载与释放EGE 的PIMAGE是有生命周期的句柄不是值类型。新手常犯的错误是在渲染循环里调getimage加载图片每帧加载几十次帧率直接崩掉因为这意味着每帧都在做磁盘读取和解码。正确的做法是启动时一次性把所有贴图装进一个资源表struct Assets { PIMAGE ground nullptr; PIMAGE sky nullptr; PIMAGE player nullptr; PIMAGE bullet nullptr; std::vectorPIMAGE boomFrames; }; bool loadOne(PIMAGE p, const char* path) { if (!p) p newimage(); if (getimage(p, path) ! grOk) { // 返回 grOk 才算成功 delimage(p); p nullptr; return false; } return true; }注意那个返回值检查。不检查的话图片路径写错时p会是一个空图后面putimage贴上去什么效果都没有也不报错你会以为是绘制逻辑的问题实际是资源没加载上。我建议在加载失败时直接printf出文件名然后退出早崩溃早发现。释放统一放在程序退出前遍历资源表逐个delimage。EGE 退出时通常能回收但反复运行调试时注意不到句柄泄漏养成手动释放的习惯将来换别的图形层时这份代码能直接复用。还有一点多张图片可以打包成一张大图再用getimage的宽高参数切分。EGE 的getimage支持从大图中按行数切分把所有帧竖着排成一张长条图一次装完后面按索引取能显著减少文件打开次数。3. 手感是参数堆出来的键盘采集、加速度与判定盒3.1 getch 的路走到头了控制台时代沿袭下来的kbhit加getch组合在图形窗口里能用但问题一大堆它只能拿到按下事件拿不到松开事件所以没法实现按住持续移动连续读取时容易丢键某些场景下还会阻塞。更要命的是它无法处理多键同时按下你想让飞机斜着飞只能是最后按下的那个方向生效。EGE 提供了消息式输入接口正确姿势是维护一个按键状态数组bool key[256] { false }; void pollInput() { ExMessage msg; while (peekmessage(msg, EM_KEY)) { // 只取键盘消息 int vk msg.vkcode 0xFF; if (msg.message WM_KEYDOWN) key[vk] true; else if (msg.message WM_KEYUP) key[vk] false; } }peekmessage是有消息就取取完返回假的非阻塞版本配合while循环能把这一帧积压的所有按键事件全部消化掉。这里必须用peekmessage而不是getmessage后者在没消息的时候会阻塞游戏就卡死了。一个隐蔽的坑窗口失去焦点时系统可能不给你发WM_KEYUP于是玩家切出去再切回来飞机就一直朝某个方向飞。检测窗口激活状态变化时清空整个key数组这个补丁一定要打。3.2 加速度模型与那张调参表直接x speed的位移式移动手感是标准的街机老古董起停硬、没有重量感、精准但乏味。射击游戏通常用加速度加阻尼的模型按下时加速接近目标速度松开时自然减速中间有个可感知的过渡。const double ACC 2600.0; // 加速度像素每二次方秒 const double MAXV 340.0; // 最大速度像素每秒 const double DAMP 12.0; // 阻尼系数 void updatePlayer(double dt) { double ax 0.0, ay 0.0; if (key[VK_LEFT]) ax - 1.0; if (key[VK_RIGHT]) ax 1.0; if (key[VK_UP]) ay - 1.0; if (key[VK_DOWN]) ay 1.0; if (ax ! 0.0) player.vx ax * ACC * dt; else player.vx - player.vx * DAMP * dt; // 松手衰减 // vy 同理 player.vx std::clamp(player.vx, -MAXV, MAXV); player.vy std::clamp(player.vy, -MAXV, MAXV); player.x player.vx * dt; player.y player.vy * dt; }这套参数调起来是有顺序的先定最大速度再定加速时间最后调阻尼。| 参数 | 我的取值 | 可调区间 | 手感表现 | | 最大速度 MAXV | 340 px/s | 240 - 500 | 太低躲不开弹幕太高容易撞墙 | | 加速度 ACC | 2600 px/s² | 1500 - 4000 | 决定达到满速的用时约 0.13 秒 | | 阻尼 DAMP | 12 /s | 6 - 20 | 越大松手停得越快越小滑行越远 | | 判定盒缩放 | 0.55 | 0.4 - 0.7 | 越小越宽容玩家越容易产生我躲得真好的错觉 |为什么最大速度是 340因为这个数值大致等于背景前景层的滚动速度玩家向前飞的时候能感觉到在追而不是永远在相对静止。这种心理上的速度一致性比任何特效都管用。还有一个细节纵向速度通常应该比横向略小因为屏幕是宽的横向移动需要跨越更长距离。我给纵向乘 0.85 的系数纯属手感取舍你可以自己试。3.3 斜向移动的归一化同时按下右和下的时候如果两个方向各自加到最大速度实际速度是sqrt(2)倍斜着飞比直着飞快 41%老玩家一眼就能看出来因为斜跑躲弹变成了最优解游戏平衡就歪了。解决办法是把输入向量归一化double len std::sqrt(ax * ax ay * ay); if (len 1.0) { ax / len; ay / len; }注意这里判断的是输入向量不是速度向量。输入向量长度为sqrt(2)时归一化成单位向量各方向的加速度变成原来的 0.707 倍最终最大速度还是 340。而速度向量本身不需要每帧归一化因为阻尼和加速度已经限定了它的上限。还有一个更微妙的处理有些游戏在斜向移动时会额外提高一点点速度乘以 1.05 左右用途是补偿斜向绕路带来的时间损失。这属于手感微调加不加都行我倾向于不加保持简单。3.4 判定盒比视觉盒小一圈这是个被讨论过无数次但每次都有人踩的设定玩家飞机的碰撞判定框一定要比画面上看到的图小。原因很直白在弹幕密度高的场景里玩家需要贴着子弹飞过去的快感。如果判定框等于图的大小玩家看见机翼尖端和子弹擦边实际上已经撞上了结果就是我明明没碰到的挫败感。把判定框缩到视觉尺寸的 55% 到 65%玩家会感觉游戏变公平了而实际难度并没有降低多少因为弹幕的密度是靠算法控制的。struct Entity { double x, y; // 世界坐标中心点 double hitW, hitH; // 判定盒尺寸 int drawW, drawH; // 视觉尺寸 int drawOffX, drawOffY;// 贴图左上角相对中心点的偏移 };把视觉尺寸和判定尺寸拆成两个字段虽然多写几行但后面调整时你会庆幸这个决定。敌机同理但通常敌机的判定盒只缩到 80% 左右因为玩家打敌机的时候是希望打到判定宽一点更爽快。玩家躲子弹和玩家打敌人心理预期是相反的两边的宽容度就该不一样。4. 对象池子弹、敌机和高频创建的那些坑4.1 每次发射都 new 的代价我第一版发射子弹的写法是bullets.push_back(Bullet{...})配合每次更新时erase掉飞出屏幕的。逻辑上完全正确实际跑起来问题在连发和高密度弹幕时出现帧时间有明显起伏几十发子弹同屏的时候开始掉帧。原因不复杂push_back触发vector扩容时要重新分配并搬移所有元素erase中间元素时要把后面的全部前移。子弹数量一多这两个操作的开销就上来了加上内存分配器本身的开销和缓存失效掉帧是必然的。对象池的思路是预先分配一批槽位用活跃标记代替增删。取出时找一个空闲槽位回收时把标记清零内存从头到尾不重新分配。4.2 一个够用的定长池template typename T, size_t N class Pool { public: T* acquire() { for (size_t i 0; i N; i) { size_t idx (cursor i) % N; if (!slots[idx].alive) { slots[idx] T{}; slots[idx].alive true; cursor (idx 1) % N; return slots[idx]; } } return nullptr; // 池满丢弃这次生成 } template typename Fn void forEachAlive(Fn fn) { for (size_t i 0; i N; i) if (slots[i].alive) fn(slots[i]); } private: T slots[N]; size_t cursor 0; };cursor这个环形起点是个小而有效的优化。没有它的话每次都从下标 0 开始扫描而如果前 90% 的槽位都是活跃的每生成一发子弹都要扫过前面全部生成成本是线性的。加上游标之后一般来说下一个槽位就是空的平均查找长度接近常数。池大小怎么定我的经验是取理论峰值 × 1.5。子弹池一般 512 就够敌机池 64粒子特效池 1024。池满了acquire返回nullptr生成方直接放弃这次生成游戏继续跑。这比让它无限增长安全至少不会因为一个弹幕爆发把内存打满。4.3 波次生成器与难度曲线敌机不能每帧随机生成得有节奏。我用一张简单的脚本表来驱动每条记录包含触发时间、敌机类型、数量、生成间隔struct WaveEntry { double atTime; // 关卡内触发时刻秒 int type; // 敌机类型 int count; // 本波数量 double interval; // 相邻两架的时间间隔 double yStart; }; static const WaveEntry WAVES[] { { 3.0, ENEMY_SCOUT, 5, 0.35, 120 }, { 9.0, ENEMY_SCOUT, 5, 0.35, 260 }, { 15.0, ENEMY_GUNNER, 2, 0.90, 180 }, { 22.0, ENEMY_SCOUT, 8, 0.25, 80 }, { 30.0, ENEMY_TANK, 1, 0.00, 200 }, };跑的时候用一个游标指着下一条未触发的记录关卡计时超过atTime就开始按interval撒怪撒完推进游标。这种脚本化的做法好处是关卡节奏完全可控哪里难哪里松一目了然调难度只改表不改代码。难度曲线方面除了固定脚本我还加了一个随时间缓慢增长的全局系数double speedScale 1.0 levelTime / 150.0; // 每 150 秒翻一倍 speedScale std::min(speedScale, 2.2);上限必须加否则玩到后面敌机速度快到根本来不及反应玩家体验是突然被秒而不是逐渐变难。4.4 遍历中删对象的三种错法用vector的时候最容易在遍历里erase出问题。三种典型错误一是erase之后迭代器失效继续it是未定义行为二是漏掉了当前元素后面的前移导致跳过了一个元素三是双重循环里同时遍历两个容器删一个的时候另一个的迭代器状态没同步。改成对象池之后这些问题从根上消失了因为删除只是把alive置假容器本身永远不变。但随之而来的是另一个坑更新和渲染的阶段要分清。如果在更新循环里刚把某个敌机标记为死亡紧接着同一个循环里就渲染它玩家会看到死亡状态的敌机还闪一下。我的做法是把一帧拆成四个阶段顺序固定阶段一是输入采集阶段二是逻辑更新阶段三是清理死亡标记阶段四是渲染。清理阶段负责把所有alive false的槽位彻底重置同时给需要生成爆炸特效的对象补上特效生成代码。这个阶段很轻但缺了它状态会跨帧残留。5. 碰撞检测矩形相交只是开头5.1 中心点写法的 AABB矩形相交是碰撞检测里最基础的一步但写法上有讲究。用左上角加宽高的写法判断要写四个条件用中心点加半宽半高两个绝对值比较就够inline bool aabb(double ax, double ay, double aw, double ah, double bx, double by, double bw, double bh) { return std::fabs(ax - bx) * 2.0 (aw bw) std::fabs(ay - by) * 2.0 (ah bh); }所有实体都存中心点坐标好处是旋转、缩放、生成粒子特效的时候都不用换算。缺点是贴图绘制时要用drawOffX和drawOffY把左上角算回来但这个换算只发生在渲染阶段不影响逻辑。至于还是这个边界讨论意义不大因为坐标是浮点精确碰等号的概率极低。真正重要的是别在判定里混用整数和浮点混用会导致边界判断时灵时不灵。5.2 高速子弹的穿透与分段采样AABB 是离散检测它假设物体在相邻两帧之间没有跨过太多距离。子弹速度一旦超过目标尺寸就会出现在第 N 帧在敌机左边、第 N1 帧在敌机右边的情况中间那一瞬间彻底漏检玩家看到的就是子弹穿过去了。三种解法从简单到复杂第一种是加粗判定盒把子弹的判定矩形拉长到它的移动距离。简单粗暴但子弹斜着飞的时候矩形朝向不对会有误判。第二种是分段采样把子弹这一帧的移动路径切成若干个小段每段用端点做一次检测。采样步长取最小目标尺寸的一半就足够double dist std::hypot(bx - bpx, by - bpy); int steps (int)std::ceil(dist / 6.0); // 6 像素采样一次 for (int i 1; i steps; i) { double t (double)i / steps; double px bpx (bx - bpx) * t; double py bpy (by - bpy) * t; if (hitAnyEnemy(px, py)) { /* 处理命中 */ break; } }第三种是射线与矩形求交数学上最准确写起来也最麻烦。射击游戏里子弹大多是直线匀速第二种方案完全够用我用了很久也没再出现过穿透。注意分段检测要在子弹的更新逻辑里做不要在碰撞阶段做。因为碰撞阶段拿到的是这一帧结束时的位置起点位置的信息已经丢了得在子弹移动前先把旧位置存下来。5.3 分层掩码别让玩家的子弹打玩家的子弹碰撞检测不能无脑两两比对。玩家子弹不该打玩家敌机子弹不该打敌机玩家不该撞自己的子弹这些规则如果写在if里会变成一大坨条件判断加一种新实体就要改一大片。分层掩码的思路是给每个实体一个uint32_t的层标记再给一份谁能打谁的表enum : uint32_t { LAYER_PLAYER 1u 0, LAYER_ENEMY 1u 1, LAYER_BULLET_P 1u 2, LAYER_BULLET_E 1u 3, LAYER_PICKUP 1u 4, }; // 每层的可命中目标掩码 uint32_t hitMask(uint32_t layer) { switch (layer) { case LAYER_BULLET_P: return LAYER_ENEMY; case LAYER_BULLET_E: return LAYER_PLAYER; case LAYER_PLAYER: return LAYER_ENEMY | LAYER_BULLET_E | LAYER_PICKUP; default: return 0; } }然后碰撞循环里只做一次判断if (hitMask(a.layer) b.layer)。加新实体的时候只改这张表循环代码一字不动。这套结构在实体种类超过四种之后收益非常明显。还有个细节同一帧里一个敌机可能被多发子弹同时命中。如果不加已死亡标记就会出现敌机掉两份血、生成两个爆炸特效的情况。解决办法是在命中处理里第一时间检查target.alive命中后立刻置假后面的子弹自然被跳过。5.4 无敌帧、命中反馈与打中了没反应玩家被击中后应该有一小段无敌时间否则连续两发子弹会造成两次伤害玩家血条瞬间清空。无敌帧的实现就是在玩家实体上放一个invincibleTimer每次被击中设为 1.5 秒更新时递减碰撞检测时小于 0 才生效。单有无敌帧还不够玩家如果不知道自己在无敌会以为自己还在受伤状态操作会变得保守。得给一个视觉反馈让飞机每 0.1 秒闪一下透明度在 40% 和 100% 之间切换。EGE 里可以用putimage_alphablend控制混合比例或者干脆隔帧不画。命中反馈里最容易被忽略的是射击感。子弹命中敌机的那一瞬间如果没有火花、没有轻微的顿帧、没有音效玩家是不确定自己打没打中的。我加了三个小东西之后同样的枪械参数手感提升非常明显命中点生成一个 3 帧的白色小圆扩散敌机受击时闪白 0.08 秒以及被击毁时轻微震屏 2 像素持续 0.1 秒。震屏就是往cam.shakeX和cam.shakeY上叠随机偏移每帧随机取值然后衰减到 0。注意随机偏移要作用在最终屏幕坐标上不能污染世界坐标否则会有累计误差。6. 主循环批量绘制、固定步长和那些闪烁6.1 BeginBatchDraw 的正确夹法EGE 的画图函数默认是画一笔刷一次画几十个图元就刷几十次画面当然是剧烈闪烁。批量绘制的用法是把所有绘制夹在BeginBatchDraw和FlushBatchDraw之间最后统一提交一次。initgraph(960, 540, INIT_RENDERMANUAL); // 手动渲染模式 setrendermode(RENDER_MANUAL); BeginBatchDraw(); while (running) { cleardevice(); drawSky(); drawParallax(); drawEntities(); drawHUD(); FlushBatchDraw(); // 一帧内容一次提交 delay_ms(1); } EndBatchDraw(); closegraph();三个函数各有分工BeginBatchDraw开启批量模式FlushBatchDraw提交当前积累的绘制内容EndBatchDraw关闭批量模式。常见错误是只调了BeginBatchDraw忘了FlushBatchDraw结果是画面永远停在第一帧或者干脆一片空白。另一个错误是把FlushBatchDraw写在循环外面。那样整个游戏运行期间只在结束时提交一次屏幕上什么都看不到而且你还以为是绘制逻辑写错了。cleardevice必须放在批量块内部、画图之前。如果放在FlushBatchDraw之后逻辑上相当于先给观众看旧画面再擦掉重画视觉上就是一闪一闪的。这个顺序问题我在早期调试时踩过找了大半天。6.2 固定步长加累积器很多教程里主循环是这种写法每帧量一下时间差把这个差值当dt传给物理更新。这在帧率稳定的时候没问题帧率一波动就出事一会儿 16 毫秒一会儿 33 毫秒同样的加速度在不同帧里积分出来的位置不一样玩家会感觉飞机忽快忽慢。固定步长把逻辑更新和渲染帧率解耦。逻辑永远是每 1/60 秒推进一次渲染帧率可以随便变const double FIXED_DT 1.0 / 60.0; double accumulator 0.0; auto last std::chrono::steady_clock::now(); while (running) { auto now std::chrono::steady_clock::now(); double frame std::chrono::durationdouble(now - last).count(); last now; if (frame 0.25) frame 0.25; // 防止卡顿后疯狂追帧 accumulator frame; pollInput(); while (accumulator FIXED_DT) { update(FIXED_DT); accumulator - FIXED_DT; } render(accumulator / FIXED_DT); // 插值系数0 到 1 之间 FlushBatchDraw(); delay_ms(1); }那个0.25的上限非常重要。没有它的话切换窗口或者拖动窗口造成 2 秒卡顿恢复后会在同一帧里连续跑 120 次update游戏直接瞬移到一个奇怪的状态甚至可能因为子弹生成数量爆炸而卡死。固定步长的代价是需要做插值否则在高刷新率屏幕上逻辑每 1/60 秒才更新一次而屏幕 144Hz 刷新会出现两帧画面一样、下一帧跳两格的顿挫。渲染时把上一帧位置和当前帧位置按alpha混合就能得到平滑过渡。我一开始没做插值在自己 60Hz 的显示器上完全看不出问题换到高刷屏上一眼就穿帮。6.3 闪烁、残影、白屏的三类成因跑批量绘制之后如果还有视觉异常基本跑不出这三类。第一类是白屏。cleardevice用背景色填充如果背景色被设成了白色而你画的图还没加载看起来就是白屏。检查方法是先画一个红色矩形测试如果红色矩形出现说明绘制链路是通的问题在资源加载。第二类是残影。只有部分旧内容没被清除时才会出现。常见原因是cleardevice只清了当前视口或者用了setviewport限定区域后忘了恢复。还有一种情况是用putimage贴了带透明通道的图但没有cleardevice透明部分会累积旧内容。第三类是撕裂。画面上下两半不同步出现在FlushBatchDraw的时机不对时。EGE 的批量提交本身有同步机制一般不会撕裂但如果自己开了双缓冲又手动提交就可能出现。这类问题没法完全消除能做的只是把绘制耗时压到刷新周期的三分之一以内。排查顺序我固定成先看cleardevice有没有在批量块内再看FlushBatchDraw有没有在循环内最后看渲染模式是不是手动模式。九成的闪烁问题都在这三步里。6.4 我留在这套循环里的几条规矩折腾到能稳定跑 60 帧之后我给自己的这套代码定了几条必须遵守的规矩写在这里当备忘。第一条所有和时间相关的量都以秒为单位代码里绝不出现每帧加多少。速度和加速度全部写成每秒、每二次方秒更新函数一律接收dt。这条规矩让代码从 30 帧切到 60 帧、切到 144 帧的时候行为完全一致不需要重新调参。第二条渲染只读不写。渲染函数里绝对不能修改任何游戏状态包括绘制后把标记清零这种看着无害的操作。渲染可能一帧执行零次也可能