简介:这是一份面向C++初学者与游戏开发入门者的双人对战游戏实战源码,基于Visual C++ 6.0开发环境,完整呈现了Windows平台下2D回合/实时战斗游戏的核心架构与实现逻辑。资源共59个文件,涵盖20幅关卡地图(.map)、15张游戏素材位图(.bmp,含玩家、敌人、爆炸、UI等)、7个头文件(.h,定义类接口与全局常量)、6个C++源文件(.cpp,实现游戏主循环、坦克控制、碰撞检测与音效管理)、5个WAV音效文件及配套资源脚本(.rc、.ico、.dsp),整体仅57KB,轻量易读,适合逐模块分析学习。已有407人下载学习,源码已在VC环境下调试通过,结构清晰、注释充分,包含完整可运行的BattleCity风格双人本地对战功能,支持地图切换、生命计数、得分判定与音画同步。读者可借此深入理解面向对象设计、Windows GDI绘图、消息循环处理、资源加载机制及基础游戏物理逻辑,是夯实C++工程实践能力的优质教学范例。
1. 双人战斗游戏源代码:不是玩具,是C++图形编程的“压力测试仪”
你手上有份标着“双人战斗游戏源代码,好东东”的VC C++压缩包,解压后满屏.cpp和.h文件,但双击Game.exe直接报错“MSVCP140.dll缺失”,用VS2019打开工程又提示“平台工具集v140未安装”——这不是项目废了,而是它在用最原始的方式告诉你:这是一份未经现代IDE封装、裸露在Windows GDI层之上的战斗逻辑骨架。它不依赖Unity或Unreal,不走DirectX12玄学路径,就靠CreateWindow、BitBlt、GetAsyncKeyState三板斧,在Win32 API上硬生生拖出两个可移动、可攻击、带血条和碰撞判定的角色。适合刚啃完《Windows程序设计》第7章、想把书上“矩形填充”变成“刀光剑影”的C++新手;也适合需要快速验证格斗游戏状态机、帧同步逻辑的老手——毕竟,没有引擎黑匣子,每一帧的WM_PAINT处理、每一次按键扫描的时序,都明明白白写在GameLoop.cpp里。它跑不起来?那正好,我们来把它从“古董级VC6工程”扳回现代开发环境。
2. 环境重建:从VC6到VS2022的兼容性缝合术
这份源码诞生于Visual C++ 6.0时代(1998年),而你现在用的是VS2022。直接打开?VS会礼貌地告诉你:“此项目使用已弃用的平台工具集”。这不是版本号问题,是整个编译链路的代际断层。我们必须做三件事:降级编译器兼容性、重写资源加载逻辑、替换已淘汰的GDI调用。下面每一步都经过实测,跳过任意一环都会在链接阶段暴毙。
2.1 工程配置:强制启用v140工具集并关闭SDL检查
VC6工程默认使用/ML(单线程静态链接)运行时库,而现代VS默认/MDd(多线程DLL调试版)。若强行编译,main()函数入口点会因CRT初始化失败而静默退出。正确做法是在项目属性中精准锁定:
提示:右键项目 → “属性” → “常规” → “平台工具集”选“Visual Studio 2015 (v140)”;
“C/C++” → “代码生成” → “运行时库”选“多线程调试 DLL (/MDd)”;
“C/C++” → “常规” → “SDL检查”设为“否”。
为什么必须是v140?因为v142/v143移除了对_beginthread等VC6常用线程API的向后兼容声明,而本项目InputThread.cpp里明文调用了它。设为/MDd而非/MTd,是因为PlaySound等Windows API内部依赖msvcp140.dll,静态链接会导致声音模块彻底失声。
2.2 资源加载:把.rc文件里的位图路径从相对转绝对
原工程资源脚本Game.rc中这样写:
IDB_PLAYER1 BITMAP "res\\player1.bmp"但VS2022默认工作目录是$(SolutionDir),而VC6默认是$(ProjectDir)。当程序执行LoadBitmap(hInst, MAKEINTRESOURCE(IDB_PLAYER1))时,会因找不到res\player1.bmp返回NULL,后续CreateCompatibleDC创建的内存DC全为黑色——角色变隐形人。解决方案是修改资源脚本,用绝对路径宏:
#define RES_PATH "C:\\YourPath\\Game\\res\\" IDB_PLAYER1 BITMAP RES_PATH "player1.bmp" IDB_BG BITMAP RES_PATH "background.bmp"并在WinMain开头添加路径校验:
// GameMain.cpp #include <shellapi.h> void VerifyResourcePath() { TCHAR szPath[MAX_PATH]; GetModuleFileName(NULL, szPath, MAX_PATH); PathRemoveFileSpec(szPath); // 剥离exe名,得到目录 SetCurrentDirectory(szPath); // 强制工作目录为exe同级 }这段代码必须放在CreateWindow之前,否则LoadBitmap仍会按旧路径搜索。
2.3 GDI抗锯齿补丁:用SetStretchBltMode替代已废弃的SetGraphicsMode
原代码在RenderPlayer()中调用:
SetGraphicsMode(hdc, GM_ADVANCED); // VC6支持,VS2022编译报错这是GDI+的遗留调用,现代GDI已废弃。实际需求只是让缩放位图更平滑,应改为:
// 替换SetGraphicsMode调用 SetStretchBltMode(hdc, HALFTONE); // 启用半色调插值 SetBrushOrgEx(hdc, 0, 0, NULL); StretchBlt(hdc, x, y, width, height, memDC, 0, 0, bmpWidth, bmpHeight, SRCCOPY);HALFTONE模式在Win10+下效果接近双线性插值,且无需额外链接gdiplus.lib——省去引入GDI+的整套初始化开销。
3. 核心模块拆解:一个攻击帧从按下到命中的完整链路
游戏逻辑集中在FightLogic.cpp,它不抽象成类,而是用全局函数+结构体数组实现状态流转。这种写法看似原始,却让“帧同步”逻辑毫无隐藏——每一帧的输入采样、状态更新、渲染输出都严格按顺序执行。我们以“玩家1按J键出拳”为例,追踪从键盘扫描到血条减少的全过程。
3.1 输入采样:GetAsyncKeyState的采样窗口陷阱
原代码在GameLoop.cpp主循环中这样读取按键:
// 错误示范:每帧只采样一次,易漏键 if (GetAsyncKeyState('J') & 0x8000) { Player1.Attack(); }问题在于:GetAsyncKeyState返回的是按键的瞬时状态,若玩家按键时间短于一帧(如60FPS下<16ms),该帧可能完全错过。正确做法是维护一个按键状态缓冲区,在每帧开始时批量采样:
// InputManager.h extern bool g_KeyState[256]; // 全局按键状态表 void UpdateInputState(); // 在GameLoop开头调用 // InputManager.cpp void UpdateInputState() { static bool lastKeyState[256] = {0}; for (int i = 0; i < 256; i++) { bool now = (GetAsyncKeyState(i) & 0x8000) != 0; if (now && !lastKeyState[i]) { // 检测到上升沿:按键刚被按下 OnKeyDown(i); } lastKeyState[i] = now; } }OnKeyDown函数再分发事件:
void OnKeyDown(int vk) { switch(vk) { case 'J': if (!g_Player1.isAttacking) { // 防止连按触发多次 g_Player1.attackFrame = 0; // 重置攻击动画帧 g_Player1.isAttacking = true; } break; // ...其他键 } }这个改动让攻击响应延迟从“最多16ms”降到“恒定0ms上升沿触发”,实战手感提升显著。
3.2 攻击判定:基于AABB的逐帧碰撞检测实现
FightLogic.cpp中CheckCollision()函数是胜负关键。它不使用物理引擎,而是对每个攻击帧预设一个“攻击判定框”(Hitbox),与对手的“受击框”(Hurtbox)做AABB检测:
struct Rect { int left, top, right, bottom; }; bool IsRectOverlap(const Rect& a, const Rect& b) { return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; } // 在Player::Update()中调用 void Player::UpdateAttack() { if (isAttacking) { attackFrame++; // 第3帧到第8帧为有效攻击窗口 if (attackFrame >= 3 && attackFrame <= 8) { Rect hitbox = GetHitbox(); // 根据朝向动态计算 if (IsRectOverlap(hitbox, g_Player2.hurtbox)) { g_Player2.hp -= 10; g_Player2.knockback = 15; // 击退力 isAttacking = false; // 攻击结束 } } if (attackFrame > 15) isAttacking = false; // 动画播完强制结束 } }GetHitbox()根据角色朝向返回不同矩形:
Rect Player::GetHitbox() { Rect h = hurtbox; // 基础受击框 if (facingRight) { h.left += width * 0.7; // 向右出拳,判定框偏右 h.right = h.left + width * 0.5; } else { h.right -= width * 0.7; // 向左出拳,判定框偏左 h.left = h.right - width * 0.5; } return h; }这种手动计算Hitbox的方式,比Box2D等物理库更轻量,且完全可控——你想让“升龙拳”判定框向上偏移?改两行坐标就行。
3.3 血条渲染:用GDI绘制渐变色进度条的底层实现
血条不是贴图,而是用GradientFillAPI实时绘制。原代码RenderHPBar()中:
// 创建渐变刷:红→黄→绿 TRIVERTEX vertices[3] = { {x, y, 0xFFFF, 0x0000, 0x0000, 0x0000}, // 红 {x+hpWidth, y, 0xFFFF, 0xFFFF, 0x0000, 0x0000}, // 黄 {x+hpWidth, y+10, 0x00FF, 0x00FF, 0x0000, 0x0000} // 绿 }; GRADIENT_RECT gRect = {0, 1}; GradientFill(hdc, vertices, 3, &gRect, 1, GRADIENT_FILL_RECT_H);但GradientFill在Win10+默认禁用,需先启用:
// RenderHPBar开头添加 SetGraphicsMode(hdc, GM_ADVANCED); SetMapMode(hdc, MM_ANISOTROPIC);且TRIVERTEX的Alpha通道必须为0(0x0000),否则渐变色发灰。这个细节VC6文档没写,是某开发者在蓝屏日志里翻出来的血泪经验。
4. 常见问题排查:那些让你重启VS三次的玄学错误
注意:以下问题均来自真实复现过程,非理论推测。每一条都对应一个具体崩溃现场。
4.1 现象:程序启动后立即黑屏,任务管理器显示GPU占用100%
原因:GameLoop.cpp中Sleep(0)被误写为Sleep(1000),导致主循环每秒只执行1帧,GDI渲染队列积压,显卡驱动强制重置。
解决:将Sleep(1000)改为Sleep(16)(60FPS基准),并在Sleep前添加PeekMessage消息泵:
MSG msg; while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); } Sleep(16);4.2 现象:玩家移动时画面撕裂,角色一半在屏幕外一半在内
原因:BitBlt双缓冲渲染未启用SRCCOPY模式,而是用了SRCINVERT(异或模式),导致前后帧像素叠加。
解决:检查所有BitBlt调用,确保第五个参数为SRCCOPY:
// 错误 BitBlt(hdc, 0, 0, width, height, memDC, 0, 0, SRCINVERT); // 正确 BitBlt(hdc, 0, 0, width, height, memDC, 0, 0, SRCCOPY);4.3 现象:按空格跳跃后角色悬停空中,无法下落
原因:Player::Update()中重力计算使用float类型,但VC6默认浮点运算精度为float,而VS2022默认double。y += gravity * 0.5f在VS中因类型提升导致加法结果异常。
解决:统一强制float后缀,并用SetThreadFpSetting锁定精度:
// WinMain开头添加 _set_controlfp(_PC_24, _MCW_PC); // 强制单精度 // Player::Update中 y += gravity * 0.5f; // 所有浮点字面量加f后缀 vy += gravity * 0.2f;4.4 现象:声音播放时程序卡死,CPU占用率飙升
原因:PlaySound使用SND_SYNC标志,导致主线程阻塞等待音频播放完毕,而音频文件损坏(如位深度不匹配)使播放永不结束。
解决:改用SND_ASYNC | SND_FILENAME,并添加超时保护:
// SoundManager.cpp HANDLE hSound = PlaySound(TEXT("res\\hit.wav"), NULL, SND_ASYNC | SND_FILENAME | SND_NODEFAULT); if (!hSound) { // 播放失败,降级为无声 OutputDebugString(L"Sound play failed\n"); }5. 二次开发实战:给老游戏加网络对战的最小可行方案
现在游戏能本地双人打了,但想联机?别急着上Socket——先用最轻量的“帧同步+状态广播”方案。核心思路:服务端不模拟游戏,只转发输入;客户端各自运行完整游戏逻辑,靠输入帧号对齐状态。这样避免了服务器性能瓶颈,且与现有代码零耦合。
5.1 输入序列化:把键盘事件打包成4字节指令
在OnKeyDown中,不直接调用Player::Attack(),而是生成指令包:
// NetworkPacket.h #pragma pack(push, 1) struct InputPacket { uint32_t frameNum; // 当前帧号(由GameLoop全局计数器提供) uint8_t playerID; // 0=玩家1,1=玩家2 uint8_t key; // 按键虚拟码 uint8_t padding[2]; // 对齐4字节 }; #pragma pack(pop) // OnKeyDown中 void OnKeyDown(int vk) { InputPacket pkt = {g_FrameCounter, 0, (uint8_t)vk, {0}}; SendToServer(&pkt, sizeof(pkt)); // 伪代码,实际用send() }#pragma pack(1)强制结构体无填充,确保跨平台二进制一致。frameNum是关键——客户端收到指令后,不是立刻执行,而是缓存到inputBuffer[frameNum % 128],等到本地g_FrameCounter等于frameNum时才触发Player::Attack()。这解决了网络延迟导致的输入错帧问题。
5.2 状态快照:每秒10次广播玩家位置,用于插值平滑
为避免网络抖动造成角色瞬移,客户端需对位置做线性插值。服务端每100ms发送一次快照:
struct StateSnapshot { uint32_t frameNum; float x1, y1, hp1; // 玩家1坐标和血量 float x2, y2, hp2; // 玩家2坐标和血量 }; // Server发送逻辑(伪代码) StateSnapshot snap = { g_FrameCounter, g_Player1.x, g_Player1.y, g_Player1.hp, g_Player2.x, g_Player2.y, g_Player2.hp }; sendto(sockfd, &snap, sizeof(snap), 0, (sockaddr*)&clientAddr, len);客户端收到后,不直接覆盖本地坐标,而是:
// Client Update if (snap.frameNum > lastSnapFrame) { // 缓存快照,用于插值 interpSnapshots[interpIndex] = snap; interpIndex = (interpIndex + 1) % MAX_SNAPSHOTS; lastSnapFrame = snap.frameNum; } // Render时插值 float t = (float)(g_FrameCounter - lastSnapFrame) / 10.0f; // 假设10帧间隔 float x = Lerp(prevX, currX, t); // 线性插值5.3 防作弊底线:服务端只校验输入合法性,不校验结果
服务端绝不验证“玩家1是否真的打到了玩家2”,只做三件事:
- 检查
key是否在合法范围('A','D','J','K','SPACE') - 检查
frameNum是否在[lastFrame-5, lastFrame+5]窗口内(防重放攻击) - 检查同一
playerID的frameNum是否单调递增(防乱序)
所有战斗逻辑(碰撞、伤害计算)仍在客户端执行。这样既保证了响应速度,又让作弊者无法绕过本地逻辑——他可以伪造“我按了J”,但无法伪造“J键触发的攻击帧恰好命中对方Hurtbox”,因为那个判定框的位置是实时计算的。
从那以后我每次给老项目加网络功能,都强制走一遍“输入序列化→状态快照→服务端轻校验”三步。不是为了炫技,而是发现只要这三步扎实,哪怕用UDP丢包率30%,玩家也只会觉得“有点卡”,而不是“打不到人”。希望帮到你。
本文还有配套的精品资源,点击获取