ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

VC++双人战斗游戏源码拆解:固定步长主循环与状态机实战

VC++双人战斗游戏源码拆解:固定步长主循环与状态机实战

简介:这份资源是面向C++初学者与游戏开发入门者的VC++双人对战游戏完整源代码,可用于在Visual C++环境下理解一个可运行小游戏的整体架构与实现逻辑,适合作为课程设计、自学练手或二次开发的参考项目。包内共59个文件,以map地图数据、bmp位图、wav音效等素材为主,配合h头文件与cpp源文件构成游戏逻辑,另有dsp工程文件、rc资源脚本与ico图标,压缩包约57KB,体积轻便却覆盖了地图关卡、图形渲染、音效播放与输入处理等模块。目前已有407人学习,说明其作为教学样例具备一定参考价值。代码已在VC下调试通过,读者可据此研究类的组织、精灵与碰撞处理、事件响应及20幅地图的关卡数据组织方式,并动手修改参数验证效果,从而把C++语法知识落到实际项目上。

1. 一份 VC C++ 源代码能省掉多少从零搭对战框架的时间

接手一份双人战斗游戏的 VC C++ 源代码,最省事的验证方式不是先读代码,而是先把它编译出来打两局——输赢不重要,重要的是确认手感、帧率和输入延迟有没有翻车。这份源码就是一个能两人同屏对打的完整工程:一套键盘分成左右两套键位,角色有走、跳、攻击、受击、死亡几个状态,碰撞用矩形做判定,渲染走的是老派窗口绘制那条路线。它适合两类人:一类是想搞懂游戏循环、状态机和帧同步思路、但不想从零搭框架的 C++ 新手;另一类是手头压着老工程、需要一份能直接改的对手逻辑做参考的熟手。这类源代码真正的价值不在画面上限,而在于结构足够小,一晚上能读完主循环,改起来不用翻十几层抽象。

2. 工程结构与技术选型:一个 Win32 窗口装下整套对战逻辑

拿到源码包后先别急着点编译,花十分钟把目录扫一遍,后面改代码时少走很多弯路。这类双人战斗项目通常拆得比较干脆:入口一个文件、游戏本体一个文件、输入、角色、碰撞、渲染各一个文件,资源单独放一个目录。模块边界清楚是它最大的优点,你后期加招式、换角色、插音效,都能定位到具体文件,而不是在一坨几千行的main.cpp里摸黑。

2.1 目录里到底有什么,每个模块负责哪一段

源码包的典型结构如下,名字可能略有出入,但职责划分基本一致:

DualFight/ ├─ src/ │ ├─ main.cpp // WinMain、窗口类注册、消息泵入口 │ ├─ GameApp.cpp // 游戏本体:主循环、局内状态调度 │ ├─ Input.cpp // 键盘采样与双人键位映射 │ ├─ Actor.cpp // 角色状态机、动作帧推进、生命值 │ ├─ Collision.cpp // AABB 碰撞与攻击判定窗口 │ ├─ Render.cpp // 位图绘制、双缓冲、血条 │ └─ Stage.cpp // 场景边界、地面高度、相机(如有) ├─ res/ │ ├─ player1.bmp // 一号玩家精灵图/单帧图 │ ├─ player2.bmp // 二号玩家 │ └─ stage.bmp // 背景 ├─ include/ // 对应的头文件 └─ DualFight.vcxproj // 老版本可能是 .dsp

main.cpp只干一件事:注册窗口类、创建窗口、进消息循环,把控制权交给GameApp。别小看这一步的分工,很多初学者把游戏逻辑塞进WndProc里,结果WM_PAINT一触发就重跑一遍逻辑,画面卡成幻灯片。GameApp.cpp才是真正的心脏,它持有两个Actor实例、一个Stage、一个输入快照,按固定节奏推进。Input.cpp负责把物理按键翻译成「左、右、跳、攻击」这种语义动作,Actor.cpp拿到语义动作后决定状态迁移,Collision.cpp在攻击帧内做矩形相交判断,Render.cpp只读数据不改数据。

这条数据流向值得记牢:输入 → 逻辑 → 碰撞 → 渲染,单向流动,不允许渲染层回头改逻辑层的数据。常见做法是在头文件里把Render的入参写成const引用,编译器帮你守住这条线,写着写着顺手改了状态这种血泪经验,靠自觉是守不住的。

2.2 为什么用 GDI 而不是引擎,这个选择的代价在哪

这类 VC 双人战斗源码基本都走 Win32 + GDI 的路子,理由很实际:零第三方依赖,一个.vcxproj拷到任何一台装了 Visual Studio 的机器上都能编,不用配 CMake、不用拉 vcpkg、不用管 DLL 版本。渲染层用的是最朴素的CreateCompatibleDC建内存 DC,SelectObject选入位图,最后一次性BitBlt到窗口 DC。这套组合在 2D 小游戏上完全够用,60 帧刷几个精灵毫无压力。

代价也摆在那:没有硬件加速,全屏分辨率和精灵数量一上去 CPU 占用立刻飙升;没有内置的精灵动画系统,帧序列要自己算;文字渲染只有TextOut,抗锯齿别想。所以它适合做「逻辑验证型」的项目,不适合做「画面表现型」的项目。如果你后期想换成 Direct2D 或者接一个轻量渲染库,Render.cpp是唯一需要重写的文件,前提是你前面把逻辑和渲染的边界守住了。

下面这段是窗口创建加双缓冲的核心骨架,几乎每个 GDI 游戏都长这样:

// main.cpp —— 创建窗口 + 建立后备缓冲,避免闪烁 static HDC g_memDC = nullptr; // 内存 DC,所有绘制先画到这里 static HBITMAP g_bmp = nullptr; // 与窗口等大的位图 static int g_width = 960; static int g_height = 540; bool CreateBackBuffer(HWND hwnd) { HDC hdc = GetDC(hwnd); g_memDC = CreateCompatibleDC(hdc); // 与屏幕兼容的内存 DC g_bmp = CreateCompatibleBitmap(hdc, g_width, g_height); SelectObject(g_memDC, g_bmp); // 位图必须选入 DC 才能画 ReleaseDC(hwnd, hdc); return g_memDC != nullptr && g_bmp != nullptr; } void Present(HWND hwnd) { HDC hdc = GetDC(hwnd); BitBlt(hdc, 0, 0, g_width, g_height, g_memDC, 0, 0, SRCCPY); // 一次性贴到屏幕 ReleaseDC(hwnd, hdc); }

CreateCompatibleDC拿到的是一块「画布」,本身没有像素存储,必须SelectObject一张位图进去才有实际内存。BitBlt的最后一个参数SRCCOPY表示直接覆盖,别用SRCINVERT之类的混合模式,除非你在做特效。g_width/g_height必须和窗口客户区严格一致——客户区尺寸和创建窗口时传的尺寸是两回事,中间差着标题栏和边框,常见做法是在WM_SIZE里GetClientRect重新取一次并重建位图,否则拉伸窗口后右下角会出现黑边或者花屏。

还有一个老生常谈的点:WM_PAINT里不要写游戏逻辑。它的触发时机由系统决定,最小化恢复、窗口遮挡都会触发,逻辑写进去就等于游戏速度被窗口管理器接管了。正确姿势是逻辑跑在主循环里,WM_PAINT里只调一次Present,或者在WM_ERASEBKGND直接返回TRUE抑制背景擦除。

2.3 编译配置:字符集、平台工具集和依赖库

工程能打开但编不过,九成是配置问题,不是代码问题。三个地方必须确认:

第一是字符集。老源码大量使用LoadImage、MessageBox这类带A/W后缀的宏,工程属性里如果设成「使用 Unicode 字符集」,宏会展开成宽字符版本,而代码里传的是char*,编译或者链接阶段就炸。快速判断方式:搜索源码里有没有LPSTR、char路径字符串,有就说明这工程原本是给多字节字符集写的,属性页把「字符集」改成「使用多字节字符集」最省事。反过来,如果你打算长期维护,建议直接改成 Unicode,把资源路径换成L"res\\player1.bmp",一次性解决。

第二是平台工具集。VC6 的.dsp用新版 VS 打不开,需要新建空项目再把源文件拖进去;VS2010 之后的.vcxproj往上兼容性较好,但要注意「Windows SDK 版本」这一项,没装对应 SDK 会报找不到windows.h。

第三是链接库。GDI 相关函数在gdi32.lib,窗口和消息在user32.lib,如果源码里用了timeGetTime、PlaySound就需要winmm.lib。现代 VS 的新建工程默认已包含前两个,老工程迁移过来时#pragma comment(lib, "winmm.lib")这种写法反而更稳:

// 放在任意一个 .cpp 顶部,比在项目属性里逐项配置更好迁移 #pragma comment(lib, "user32.lib") #pragma comment(lib, "gdi32.lib") #pragma comment(lib, "winmm.lib") // 用到 timeGetTime / PlaySound 时必须

#pragma comment的好处是跟着源码走,换台机器、新建工程都不用重新点属性页。顺带提一句运行库:如果源码里用了malloc/free而工程设的是/MD(动态运行库),分发给没装 VC 运行库的机器会提示缺 DLL;改成/MT静态链接,生成的 exe 大几百 KB,但拷过去就能跑,做小工具我一般默认/MT。

3. 核心循环拆解:一帧里角色、输入、碰撞怎么排序

主循环是整份源码里最值得逐行读的部分,它决定了游戏的手感天花板。很多双人战斗项目「看着能跑但打起来怪」,问题往往不在角色逻辑,而在循环的时序安排:输入采样放在渲染之后、逻辑步长跟着帧率浮动、碰撞检测在状态推进之前跑,三处任意一个出问题,表现出来的都是「按键有延迟」「判定飘」「高刷屏上动作快一倍」。

3.1 固定步长主循环:把逻辑和渲染解耦

先看结构。老派写法是while(1) { 处理消息; 更新; 绘制; Sleep(16); },简单但脆——Sleep(16)不保证 16 毫秒,机器一忙就掉帧,逻辑速度跟着掉,两个人对打时手感完全不一致。合格的做法是固定步长累加器:

// GameApp.cpp —— 固定 60Hz 逻辑步长,渲染跟随实际帧率 const float FIXED_DT = 1.0f / 60.0f; // 每个逻辑步固定 16.67ms float g_accum = 0.0f; LARGE_INTEGER freq, prev, now; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&prev); while (g_running) { MSG msg; while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { // 非阻塞取消息 if (msg.message == WM_QUIT) g_running = false; TranslateMessage(&msg); DispatchMessage(&msg); } QueryPerformanceCounter(&now); float frame = float(now.QuadPart - prev.QuadPart) / float(freq.QuadPart); prev = now; if (frame > 0.25f) frame = 0.25f; // 断点调试/窗口拖拽后防止逻辑一次性爆冲 g_accum += frame; while (g_accum >= FIXED_DT) { // 逻辑按固定步长追帧 Input_Sample(); // 1. 采样输入快照 Actor_Update(FIXED_DT); // 2. 推进角色状态机 Collision_Resolve(); // 3. 判定命中与伤害 g_accum -= FIXED_DT; } Render_Draw(g_accum / FIXED_DT); // 4. 用插值系数画,减少抖动 }

几个关键点解释一下。PeekMessage和GetMessage的区别是前者不阻塞,没有消息立刻返回,循环才能持续跑;如果用GetMessage,窗口没消息时线程挂起,游戏就停在那里等鼠标动。frame > 0.25f那个钳制是后悔药:调试时断点停几秒,恢复后累加器一下攒了几百毫秒,while会连续跑几十次逻辑,表现就是角色瞬移。Render_Draw传进去的插值系数是为了让渲染在两个逻辑步之间做线性插值,不传也能跑,但低帧率下看着会一顿一顿。

FIXED_DT是这套结构的基石。所有速度、加速度、计时都按「每秒多少单位」写,然后乘dt,比如pos.x += speed * dt。一旦你在某处写了pos.x += 5;这种不乘dt的硬编码,这个角色的移动速度就和机器性能绑定了,两台电脑对打必然不同步——这是后面章节要重点说的坑。

3.2 双人输入映射:一套键盘怎么分出两套键位

同屏双人最现实的输入设备就是一块键盘,左边的人用WASD加J,右边的人用小键盘或者方向键加数字键。这里最容易犯的错是直接在WndProc的WM_KEYDOWN里改角色状态,结果按住方向键时系统自动重复发WM_KEYDOWN,角色一跳一停,非常难受。

正确做法是消息只负责记录「哪个键按下/抬起」,逻辑层每帧读一次快照:

// Input.cpp —— 键位表 + 每帧快照,避免消息重复触发导致粘键 struct KeyMap { int left, right, jump, attack; // 虚拟键码 }; static const KeyMap kP1 = { 'A', 'D', 'W', 'J' }; // 玩家一的键位 static const KeyMap kP2 = { VK_LEFT, VK_RIGHT, VK_UP, VK_NUMPAD1 }; // 玩家二 static bool g_curr[256] = { false }; // 本帧按键状态 static bool g_prev[256] = { false }; // 上一帧按键状态 void Input_OnKeyDown(int vk) { g_curr[vk] = true; } // WndProc 里调用 void Input_OnKeyUp(int vk) { g_curr[vk] = false; } // WndProc 里调用 // 每帧开头调用:把 curr 拷到 prev,再统一采样 void Input_Sample() { // 这里只做快照,真正的按下/抬起判断留给下面两个函数 } bool KeyDown(int vk) { return g_curr[vk]; } // 持续按住 bool KeyPressed(int vk) { return g_curr[vk] && !g_prev[vk]; } // 本帧刚按下 bool KeyReleased(int vk){ return !g_curr[vk] && g_prev[vk]; } // 本帧刚抬起

g_curr和g_prev的双缓冲是核心。KeyDown用于「按住持续移动」,KeyPressed用于「跳一下、挥一刀」这种一次触发,KeyReleased用于「蓄力松开」。如果只用一个数组,判断不出「刚按下」这个瞬间,跳跃逻辑就只能写成持续按住就持续起跳,手感变成弹簧人。

Input_Sample里那个空实现要补上真实工作:在逻辑步推进前把g_curr复制进g_prev。注意复制时机必须在逻辑读完之后、下一帧开始之前,否则KeyPressed永远是false。我一般把它放在while (g_accum >= FIXED_DT)循环体内部的第一行,这样连续跑多个逻辑步时每一帧都能正确识别边沿。

还有个实战细节:玩家二用方向键时,部分笔记本键盘和某些机械键盘存在「多键冲突」,同时按左 + 上 + 小键盘1可能识别不到第三个键。这不是代码问题,是键盘矩阵的物理限制。改键位到小键盘 4/6/8/0通常能缓解,因为小键盘走独立扫描码链路;实在要稳妥,就上手柄或者改成鼠标点击出招。

3.3 角色状态机:攻击判定窗口怎么设才打得到人

角色不是一堆if堆出来的,而是一个显式状态机。状态决定当前能接受哪些输入、播放哪一帧、有没有判定框:

// Actor.h —— 状态枚举与角色数据结构 enum class ActorState { Idle, Walk, Jump, Attack, Hurt, Dead }; struct AttackWindow { int startFrame; // 攻击判定生效的起始帧(相对动作第 0 帧) int endFrame; // 判定失效帧,超过就不再命中 RECT hitBox; // 相对角色原点的判定矩形 int damage; // 命中后的伤害值 }; struct Actor { ActorState state = ActorState::Idle; int frameIndex = 0; // 当前动作播到第几帧 int frameTimer = 0; // 帧计时器(毫秒) int hp = 100; int invincible = 0; // 无敌帧剩余时长,>0 时忽略伤害 float x = 0, y = 0, vx = 0, vy = 0; bool facingRight = true; AttackWindow attack; // 由角色配置表填充 };

startFrame/endFrame这两个数字是整份源码里最需要调的地方,调不好就是「挥舞得挺像,就是打不到」。判定窗口开太早,动画还没挥出去就伤人,看着像隔空打牛;开太晚,动作收尾才生效,对手早跑了。常见做法是让判定窗口覆盖「武器/拳头到达最远点」的那两三帧,比如 12 帧的攻击动作,判定开在 4~6 帧。

这里有个必须说穿的细节:frameIndex是逻辑帧还是渲染帧。如果按渲染帧推,逻辑步固定 60Hz 但显示器 144Hz,动作会播快两倍多。所以帧推进必须写在Actor_Update(dt)里,靠dt累加到frameTimer,超过单帧时长再frameIndex++。判定窗口的判断也就跟着逻辑帧走,天然和主循环同步:

// Actor.cpp —— 状态推进与攻击窗口判定 void Actor_Update(Actor& a, float dt) { a.frameTimer += int(dt * 1000.0f); while (a.frameTimer >= kFrameDurationMs[a.state]) { // 单帧时长按状态查表 a.frameTimer -= kFrameDurationMs[a.state]; ++a.frameIndex; } switch (a.state) { case ActorState::Attack: // 判定生效区间内,每次逻辑步都尝试结算一次命中 if (a.frameIndex >= a.attack.startFrame && a.frameIndex <= a.attack.endFrame) { Collision_TryHit(a); } if (a.frameIndex >= kAttackTotalFrames) { // 动作播完回到待机 a.state = ActorState::Idle; a.frameIndex = 0; } break; case ActorState::Hurt: if (a.invincible > 0) a.invincible -= int(dt * 1000.0f); if (a.frameIndex >= kHurtTotalFrames) a.state = ActorState::Idle; break; default: break; } }

kFrameDurationMs是按状态区分每帧显示时长的表,比如走路 100ms 一帧、攻击 60ms 一帧,让动作有快慢节奏。如果全部用同一个值,攻击会软绵绵的没有打击感。这张表放在配置里用数组写死就行,别急着上 JSON 解析,先把动作调顺了再说。

3.4 碰撞与伤害结算:矩形相交只是第一步

碰撞检测本身简单,两个RECT相交判断四行代码。真正容易出问题的是坐标系和结算顺序:

// Collision.cpp —— AABB 相交 + 命中结算 static bool AabbHit(const RECT& a, const RECT& b) { return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; } void Collision_TryHit(Actor& attacker) { Actor& defender = (&attacker == &g_player1) ? g_player2 : g_player1; if (defender.state == ActorState::Dead) return; if (defender.invincible > 0) return; // 受击后短暂无敌,防止连击锁死 // 攻击判定框是局部坐标,必须平移到世界坐标再比较 RECT atk = attacker.attack.hitBox; if (!attacker.facingRight) { // 朝左时水平镜像 LONG l = atk.left, r = atk.right; atk.left = -r; atk.right = -l; } OffsetRect(&atk, (int)attacker.x, (int)attacker.y); RECT body = { (int)defender.x, (int)defender.y, (int)defender.x + kActorWidth, (int)defender.y + kActorHeight }; if (!AabbHit(atk, body)) return; defender.hp -= attacker.attack.damage; defender.state = ActorState::Hurt; defender.frameIndex = 0; defender.invincible = kHurtInvincibleMs; // 比如 300ms if (defender.hp <= 0) defender.state = ActorState::Dead; }

OffsetRect那一步是关键。判定框在配置里一般是相对角色原点的局部坐标(比如{-10, -30, 40, 0}表示身前一块区域),直接拿它和世界坐标的角色身体比较,永远是错的。同理,facingRight == false时必须把判定框做水平镜像,否则角色转身之后伤害还落在原来那侧,看起来像背后打人。

invincible这个字段不能省。没有它,攻击窗口内的每一逻辑步都会命中一次,60Hz 下 3 帧窗口就是 3 次伤害,配上伤害值直接秒杀。常见做法是受击后给 200~400 毫秒无敌,同时把角色击退一小段,让对打有来有回。

顺序也要注意:Collision_Resolve必须放在双方Actor_Update之后。如果先结算再推进状态,会出现「这一帧的血量扣了,但受击动画要等到下一帧才开始播」,中间那一帧角色还在攻击动作里僵着,看着很怪。

4. 从源码到开打:编译、调试与第一个可玩版本

代码读明白了,接下来要让它在自己机器上跑起来。这一步看着简单,实际卡人的地方不少:工程打不开、编过了但黑屏、有画面但按键没反应。按下面的顺序走,基本能一次通。

4.1 工程导入与必查配置项

先判断工程文件年份。.dsp/.dsw是 VC6 时代的,新版 VS 直接打开会提示转换失败。做法是:VS 里「新建空项目」→ 把src和include下的文件全部「添加现有项」→ 手动配一遍属性。.vcxproj相对好办,双击打开,如果提示「平台工具集未安装」,右键项目 → 属性 → 常规 → 平台工具集,选一个本机装了的。

必查清单如下:

配置项位置该设成什么设错的症状
字符集属性 → 高级 → 字符集多字节(老源码)或全局改 UnicodeLoadImage报错、路径乱码
平台工具集属性 → 常规本机已安装的版本打开项目即报错
运行库属性 → C/C++ → 代码生成/MT便于分发,/MD便于调试换机器缺 DLL
附加依赖项属性 → 链接器 → 输入winmm.lib(用到计时/音效)LNK2019 未解析符号
子系统属性 → 链接器 → 系统WINDOWS启动时弹出控制台
工作目录属性 → 调试 → 工作目录$(ProjectDir)资源加载失败,图片全空白

「工作目录」这一项最容易被忽略。VS 调试时默认工作目录是工程目录,但如果源码里用相对路径res\\player1.bmp加载资源,而你从别的路径启动 exe(比如直接双击Debug目录下的可执行文件),相对路径就会失效,表现是画面里角色凭空消失,只有背景能画出来。稳妥做法是用GetModuleFileName拿到 exe 所在目录,再拼资源路径:

// 拼出 exe 同级的 res 目录,避免依赖当前工作目录 std::string GetResPath(const char* filename) { char exePath[MAX_PATH] = { 0 }; GetModuleFileNameA(nullptr, exePath, MAX_PATH); // 拿到完整 exe 路径 std::string dir(exePath); size_t pos = dir.find_last_of("\\/"); if (pos != std::string::npos) dir = dir.substr(0, pos + 1); return dir + "res\\" + filename; // 与 exe 同级的 res 目录 }

GetModuleFileNameA传nullptr表示取当前进程的 exe 路径,返回的是完整路径含文件名,所以要截到最后一个分隔符为止。这样无论从 VS 启动还是双击 exe,资源路径都一致。

4.2 资源加载:位图从磁盘到内存 DC 的完整链路

GDI 加载位图有个固定的三步套路,缺一步就是黑块或者透明色丢失:

// Render.cpp —— 加载位图并建立透明遮罩,支持色键透明 struct Sprite { HBITMAP hColor = nullptr; // 彩色位图 HBITMAP hMask = nullptr; // 单色遮罩,用于透明绘制 int w = 0, h = 0; }; bool LoadSprite(Sprite& s, const std::string& path, COLORREF keyColor) { s.hColor = (HBITMAP)LoadImageA(nullptr, path.c_str(), IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE | LR_CREATEDIBSECTION); if (!s.hColor) return false; BITMAP bm; GetObject(s.hColor, sizeof(bm), &bm); // 取宽高,后面算遮罩要用 s.w = bm.bmWidth; s.h = bm.bmHeight; // 用彩色位图生成单色遮罩:与 keyColor 相同的像素置白(透明),其余置黑 HDC hdcSrc = CreateCompatibleDC(nullptr); HDC hdcDst = CreateCompatibleDC(nullptr); SelectObject(hdcSrc, s.hColor); s.hMask = CreateBitmap(s.w, s.h, 1, 1, nullptr); // 1 位色深,只有黑白 SelectObject(hdcDst, s.hMask); SetBkColor(hdcSrc, keyColor); BitBlt(hdcDst, 0, 0, s.w, s.h, hdcSrc, 0, 0, SRCCOPY); DeleteDC(hdcSrc); DeleteDC(hdcDst); return true; }

LoadImageA的LR_LOADFROMFILE表示从磁盘文件加载,LR_CREATEDIBSECTION保证拿到的位图能GetObject出正确的像素格式信息。SetBkColor配合BitBlt做的是色键转换:把原图中等于keyColor的像素在遮罩里变成白色,其他变黑色。绘制时先用SRCAND贴遮罩、再用SRCPAINT贴彩色图,被遮罩挡住的区域就透明了。

keyColor一般是精灵图的背景色,常见是纯品红RGB(255,0,255)或者纯青。选色键的原则是这个颜色在角色身上绝不出现,否则角色身上会出现透明窟窿。如果资源本身是带 alpha 通道的 PNG,那 GDI 这条路走不通,要么换成 32 位 DIB 手动做 alpha 混合,要么换渲染后端。

4.3 调试手段:把看不见的状态画出来

对战游戏的 bug 大多长这样:动作看着正常,但判定就是不对;或者角色掉到地面以下没了。光看画面猜不出来,得把内部状态可视化。最快的方式是在Render_Draw末尾加一个调试开关,把碰撞盒、判定框、状态名直接画上去:

// Render.cpp —— 调试叠加层,仅 Debug 构建生效 void Render_DebugOverlay(HDC memDC, const Actor& a) { #ifdef _DEBUG // 身体碰撞盒:绿色边框 HPEN penBody = CreatePen(PS_SOLID, 1, RGB(0, 255, 0)); HPEN old = (HPEN)SelectObject(memDC, penBody); HBRUSH oldBrush = (HBRUSH)SelectObject(memDC, GetStockObject(NULL_BRUSH)); Rectangle(memDC, (int)a.x, (int)a.y, (int)a.x + kActorWidth, (int)a.y + kActorHeight); // 攻击判定框:仅在窗口期内画红色,直观看判定时机 if (a.state == ActorState::Attack && a.frameIndex >= a.attack.startFrame && a.frameIndex <= a.attack.endFrame) { HPEN penAtk = CreatePen(PS_SOLID, 1, RGB(255, 0, 0)); SelectObject(memDC, penAtk); RECT hit = a.attack.hitBox; OffsetRect(&hit, (int)a.x, (int)a.y); Rectangle(memDC, hit.left, hit.top, hit.right, hit.bottom); DeleteObject(penAtk); } SelectObject(memDC, old); SelectObject(memDC, oldBrush); DeleteObject(penBody); #endif }

NULL_BRUSH让矩形只画边框不填充,否则会盖住角色。_DEBUG宏保证发布版本里这段代码不参与编译,零开销。加上这一层之后,「打不到人」的问题基本能一眼定位:要么红色框根本没出现(判定窗口没进),要么出现了但和对方绿框没交集(判定框偏了),要么两个框明明重叠却没扣血(结算逻辑被无敌帧挡了)。

同理,血量和状态名用TextOut画在左上角,一秒钟的事,省下的是几个小时的猜测。这类小工程我从来不推崇上来就配调试器断点——断点会打乱主循环的时序,问题往往一断点就复现不了,屏幕上直接打日志反而更接近真实运行状态。

5. 踩坑与排查:移植这份源码时最容易翻车的五处

每个坑都按「现象 → 原因 → 解决」写,都是这类 GDI 双人战斗工程里反复出现的。

5.1 编译期问题:链接错误和字符集报错

现象:编译通过,链接阶段报LNK2019: 无法解析的外部符号 _timeGetTime@0,或者LoadImage相关的LNK2019。

原因:timeGetTime在winmm.lib里,工程没有链接这个库;LoadImage的宏展开取决于字符集,如果工程设成 Unicode 而源码传的是窄字符串,展开出的LoadImageW找不到匹配的重载,或者你手工声明了函数原型和实际展开的名字不一致。

解决:在任意.cpp顶部加#pragma comment(lib, "winmm.lib");字符集统一,老源码直接改成多字节,新写的部分用TCHAR系列保持中立。别在项目属性里零零散散加依赖,换台机器就丢。

现象:报一堆C2664: 无法将参数从 const char* 转换为 LPCWSTR。

原因:典型的字符集混用,源码里写的是"res\\a.bmp",工程是 Unicode 配置。

解决:要么统一改多字节,要么把所有字符串常量加L前缀并改用LoadImageW/CreateFileW。两种都行,混着来最痛苦。

5.2 画面闪烁与残影:双缓冲没做对

现象:角色移动时整块画面闪,或者移动过的地方留下残影,像拖了条尾巴。

原因:两种情况。第一种是直接往窗口 DC 上画,每画一个元素系统就刷一次,中间态肉眼可见;第二种是做了内存 DC 但每帧忘了清空,上一帧的像素还在,新帧叠上去就成了重影。

解决:所有绘制走内存 DC,最后一次性BitBlt;每帧开头用背景色FillRect或先贴背景图,把整块画布擦干净再画精灵。顺带在WM_ERASEBKGND里return TRUE,阻止系统再擦一遍背景,能省一次全屏填充。

现象:拉伸窗口后右下角出现黑边或者花屏。

原因:后备缓冲的尺寸在启动时固定了,窗口尺寸变化后没重建位图,BitBlt源区域小于目标区域。

解决:在WM_SIZE里GetClientRect取新尺寸,DeleteObject旧位图、重建新的,注意先SelectObject回旧对象再删,否则位图还被 DC 引用着,删不掉造成泄漏。

5.3 手感异常:逻辑速度和帧率绑在一起

现象:在高刷新率显示器上角色走路明显快一截,或者两台机器对打时同一招式伤害次数不一样。

原因:逻辑里写了不乘dt的位移,或者把frameIndex++放在了渲染函数里。渲染帧率和显示器刷新率挂钩,逻辑速度跟着漂。

解决:全工程搜一遍x +=/y +=,确认每一项都乘了dt;帧推进挪进Actor_Update固定步长里。改完之后在 60Hz 和 144Hz 两台机器上各跑一遍,对比同一个动作的帧数和位移,一致才算过。

现象:调试时断点停一下再继续,角色瞬移到对面墙角。

原因:累加器在断点期间攒了大量时间,恢复后while连续跑了几十次逻辑。

解决:单帧时间钳制,if (frame > 0.25f) frame = 0.25f;。一行代码解决,但没有它就是玄学 bug。

5.4 输入相关:粘键、丢键和方向反了

现象:按住跳跃键角色连跳,按一下也跳好几次。

原因:逻辑直接读WM_KEYDOWN,系统自动重复会持续投递这个消息;或者只用了「按下」状态没有「刚按下」边沿判断。

解决:消息只更新按键状态表,逻辑层用KeyPressed判断本帧刚按下。同时检查有没有在WM_KEYDOWN里直接改状态。

现象:玩家二按方向键没反应,或者两个人按键互相干扰。

原因:WndProc里只处理了一套键位;或者键盘本身多键冲突。

解决:确认两套键位表都注册进了输入采样;物理冲突的换键位到小键盘区域,或者干脆改成鼠标左键攻击。

现象:角色转身之后判定框还在原来那侧,看着像背后打人。

原因:判定框是局部坐标,朝向改变时忘了水平镜像。

解决:在偏移到世界坐标前先按朝向翻一次left/right,翻完再OffsetRect。顺序反了会算出负宽度的矩形,AabbHit恒为假。

5.5 结算逻辑:伤害扣多了或者一遍都扣不上

现象:一次攻击扣了三管血,或者命中判定对了但血量不变。

原因:前者是攻击窗口内每逻辑步都结算一次,没有去重;后者一般是无敌帧没到期,或者血条的绘制数据源和实际血量不是同一个变量。

解决:给每次攻击加一个hasHitThisSwing标记,命中一次后窗口内不再结算,动作结束时清零;受击无敌时间设 200~400ms。血条务必直接读actor.hp,别在渲染层再维护一份镜像数值——两份数据不同步是这类工程最常见的隐形 bug。

6. 二次开发:把回合制和三局两胜塞进现有状态机

源码能跑之后,大多数人第一个想法是加个胜负判定和回合制,让它像个完整的对战游戏。这个改造看着要动很多东西,其实只需要在现有状态机外面套一层「局状态」,不用碰角色逻辑。

思路是把「局」当成一个上层状态机,和ActorState平级但层次更高:RoundIntro(倒计时)→Fighting(正常对战)→RoundEnd(胜者结算、短暂停顿)→ 下一局或者MatchEnd。角色状态机在RoundIntro和RoundEnd阶段不接收输入,只播待机动画,避免玩家在倒计时里提前出招。

// Match.h —— 局状态与计分,套在角色状态机外面 enum class RoundState { Intro, Fighting, RoundEnd, MatchEnd }; struct Match { RoundState state = RoundState::Intro; int timerMs = 3000; // 开场倒计时 3 秒 int p1Wins = 0; int p2Wins = 0; int roundNo = 1; const int kWinNeeded = 2; // 三局两胜 }; void Match_Update(Match& m, Actor& p1, Actor& p2, float dt) { switch (m.state) { case RoundState::Intro: m.timerMs -= int(dt * 1000.0f); if (m.timerMs <= 0) { m.state = RoundState::Fighting; } // 倒计时结束开打 break; case RoundState::Fighting: if (p1.hp <= 0 || p2.hp <= 0) { (p1.hp <= 0 ? m.p2Wins : m.p1Wins) += 1; m.state = RoundState::RoundEnd; m.timerMs = 2000; // 结算停顿 2 秒 } break; case RoundState::RoundEnd: m.timerMs -= int(dt * 1000.0f); if (m.timerMs <= 0) { if (m.p1Wins >= m.kWinNeeded || m.p2Wins >= m.kWinNeeded) { m.state = RoundState::MatchEnd; } else { p1.hp = p2.hp = 100; // 重置血量,位置也要一并重置 p1.state = p2.state = ActorState::Idle; m.roundNo += 1; m.timerMs = 3000; m.state = RoundState::Intro; } } break; default: break; } }

这个结构的验证方法很直接:把kWinNeeded临时改成 1,两秒内就能把「开局 → 打斗 → 结算 → 结束」整条链路走完;改成 2 再验证回合重置时血量、位置、状态、按键快照有没有全部归位。最容易漏的是按键快照——上一局结束时玩家还按着攻击键,新一局开场直接挥一刀,所以重置时要把g_prev和g_curr都清一遍。

再往下走就是联机,这里我得泼点冷水。这份源码的坐标、判定、帧推进全是本地时钟驱动的,直接把它改成「一份逻辑跑两端」需要额外的同步层:至少要有输入帧缓冲、帧号对齐、延迟补偿。常见的低成本做法是改成回合制定向同步——两端只同步「这一帧玩家按了什么」,逻辑各自跑,因为固定步长 + 确定性逻辑,结果自然一致;但这要求你去掉所有浮点随机数和依赖系统时间的逻辑,把rand()换成带种子的伪随机,两端种子同步下发。这一步不做,联机版必然出现两端血量对不上的黑匣子问题。

最后说个我自己的习惯。每次改完这类状态机代码,我都会强制走一遍「三局全流程 + 断点停留 5 秒 + 窗口拉伸到最小」这三步再提交。前两步抓的是时序和时间钳制问题,第三步抓的是后备缓冲的尺寸重建。这套流程救过我很多次——曾经有一次加回合制,忘了在重置时清无敌帧,第二局开场双方都打不动对面,看着像输入失灵,查了两个小时才定位到那个没归零的invincible。从那以后我每次加新状态,都先把所有需要归零的字段列一遍对着清。希望这份拆解帮到你,把源码跑起来之后,先别急着加功能,先把判定框画出来打十分钟,比读十遍代码都管用。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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