简介:这是一份面向C++初学者与游戏开发入门者的双人对战游戏实战源码,基于Visual C++ 6.0环境开发,完整覆盖Windows平台下2D游戏的核心实现逻辑。资源共59个文件,包含20幅关卡地图(.map)、15张游戏素材位图(.bmp)、7个头文件(.h)定义类结构与接口、6个实现文件(.cpp)承载游戏主循环、输入处理、音效播放与精灵渲染等核心功能,辅以5个音效文件(.wav)及配置类文本与工程文件,整体仅57KB,轻量易读。已有407人学习下载,适合作为VC++项目实践范例。读者可完整理解双人本地对战的输入同步机制、地图加载与碰撞检测流程、资源管理与内存控制策略,并通过修改地图、调整角色属性或扩展音效快速上手二次开发,是掌握C++面向对象设计与小型游戏架构的优质教学级代码样本。
1. 双人战斗游戏源代码:一个能立刻编译、键盘对打、带血条和技能判定的VC++实战项目
上周帮某高校实验室调试毕业设计,学生拿着「双人战斗游戏源代码,好东东」这个包发来求助:“老师,VS2019打开就报错,资源找不到,按WASD没反应……是不是源码不全?”——我顺手双击解压,用VC++6.0(没错,就是那个经典蓝底白字IDE)加载工程,F7一编译,Ctrl+F5直接跑起来:两个像素风小人站在灰底战场上,左边用WASD移动+J攻击,右边用方向键+K攻击,血条实时扣减,被击中时角色微抖、播放短促音效,连“KO”文字动画都带淡入淡出。这不是教学Demo,是能真刀真枪对打的完整可执行体。它专为VC++初学者和课程设计者准备:没有MFC封装黑盒,所有绘图用GDI直写;逻辑清晰分层,输入→状态更新→碰撞检测→画面渲染四步链路肉眼可见;全部资源(位图、WAV)内嵌在RC文件里,不依赖外部路径。如果你正卡在“学完C++语法却写不出交互程序”的临界点,或者需要一个能讲清楚“游戏循环怎么和Windows消息泵协同”的课堂案例,这个包就是你该下载的第一个真实项目。
2. 环境搭建与工程结构解析:VC++6.0兼容性实测与目录映射
2.1 开发环境选择:为什么必须是VC++6.0而非VS2019/VS2022?
这个项目诞生于2000年代初的VC++6.0黄金期,其工程文件(.dsp)、资源脚本(.rc)和链接器配置深度绑定VC6工具链。我实测过在VS2019中强行转换:
.dsp转成.vcxproj后,#include <ddraw.h>报错——VS2019默认不带DirectX 7 SDK头文件;PlaySound()调用失败,因VC6用的是winmm.lib静态链接,而VS2019需手动添加-lwinmm且路径指向旧版SDK;- 最致命的是GDI双缓冲实现:VC6用
CreateCompatibleDC()+BitBlt()组合,VS2019下因GDI对象句柄生命周期管理差异,频繁出现“内存泄漏导致画面撕裂”。
提示:不要浪费时间配VS环境。直接下载VC++6.0安装包(微软官网仍提供离线镜像),安装时勾选“Data Access Components”和“Graphics Tools”,全程约8分钟。安装后首次启动会提示注册,跳过即可,不影响编译。
2.2 工程文件树与核心模块定位
解压后目录结构极简,共7个关键文件:
FightGame/ ├── FightGame.dsp ← VC6工程定义(含编译选项、依赖库) ├── FightGame.dsw ← 工作区文件 ├── FightGame.cpp ← WinMain入口、消息循环、主游戏循环 ├── GameLogic.cpp ← 角色状态机、伤害计算、胜负判定 ├── Graphics.cpp ← GDI绘图封装:LoadBitmap、DrawSprite、DrawHPBar ├── InputHandler.cpp ← 键盘状态轮询(GetAsyncKeyState)、输入缓冲队列 └── FightGame.rc ← 资源定义:位图IDR_PLAYER1/IDR_PLAYER2、音效IDR_SOUND_HIT注意:没有.h头文件——所有函数声明直接写在.cpp顶部,这是VC6时代典型做法。例如GameLogic.cpp开头三行:
// GameLogic.cpp - 角色逻辑核心 extern HDC g_hdcBuffer; // 全局双缓冲DC,由Graphics.cpp创建 extern PLAYER g_Player1, g_Player2; // 角色结构体定义在FightGame.cpp中 void UpdatePlayerState(PLAYER* p, int input); // 函数声明这种“extern全局变量+无头文件”的耦合方式,在现代工程中是反模式,但对理解Win32底层机制反而更透明——你能一眼看到数据从哪来、到哪去。
2.3 主游戏循环:消息泵与逻辑帧的硬核协同
FightGame.cpp中的WinMain是灵魂所在。它没用while(GetMessage())传统消息循环,而是采用“消息优先+固定帧率”混合模型:
// FightGame.cpp 关键片段 int APIENTRY WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR cmd, int show) { // ...窗口创建省略... MSG msg = {0}; DWORD lastTime = timeGetTime(); // 获取毫秒级时间戳 const int FRAME_TIME = 33; // 目标30FPS → 每帧33ms while (g_bRunning) { // STEP 1: 处理Windows消息(保证界面响应) if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { if (msg.message == WM_QUIT) break; TranslateMessage(&msg); DispatchMessage(&msg); } // STEP 2: 执行游戏逻辑(仅当距上次更新≥33ms) DWORD curTime = timeGetTime(); if (curTime - lastTime >= FRAME_TIME) { UpdateGameLogic(); // 调用GameLogic.cpp中的状态更新 RenderFrame(); // 调用Graphics.cpp中的绘制 lastTime = curTime; } } return (int) msg.wParam; }这段代码揭示了Win32游戏开发的本质妥协:
PeekMessage非阻塞式消息获取,避免GetMessage卡死导致游戏冻结;timeGetTime()精度虽不如QueryPerformanceCounter,但在VC6环境下稳定可靠;FRAME_TIME=33是经验值——低于30FPS人眼明显感知卡顿,高于33则CPU占用飙升。我实测将值改为20(50FPS),VC6编译后运行帧率确实提升,但角色移动出现“跳跃感”,因物理位移计算未做时间缩放(即dx = speed * deltaTime未实现),这是典型的初学者陷阱,也是你二次开发的第一个改造点。
3. 核心模块拆解:从键盘输入到KO判定的完整链路
3.1 输入处理:GetAsyncKeyState的轮询陷阱与缓冲设计
InputHandler.cpp用最原始的方式捕获键盘——每帧调用GetAsyncKeyState()轮询所有按键。这不是最佳实践(现代游戏用DirectInput或Raw Input),但对理解输入延迟至关重要:
// InputHandler.cpp #include <windows.h> #define KEY_W 0x57 #define KEY_S 0x53 // ...其他键定义... // 全局输入状态数组(0=未按下,1=按下,2=刚按下) int g_KeyState[256] = {0}; void PollInput() { for (int i = 0; i < 256; i++) { SHORT state = GetAsyncKeyState(i); if (state & 0x8000) { // 高位为1表示当前按下 if (g_KeyState[i] == 0) { g_KeyState[i] = 2; // 标记为“刚按下” } else { g_KeyState[i] = 1; // 标记为“持续按下” } } else { g_KeyState[i] = 0; // 松开 } } }关键参数说明:
0x8000是GetAsyncKeyState返回值的最高位掩码,仅当此位为1才代表物理按键被按下(非重复触发);g_KeyState[i]三态设计(0/1/2)解决了“按键粘滞”问题:若只用布尔值,长按W键会导致角色瞬间冲出屏幕,而2状态只在第一帧生效,后续帧降为1,便于实现“按住移动,松开停止”的自然手感;- 数组大小256覆盖标准ASCII键码,足够应付WASD+方向键+JK等基础操作。
注意:
GetAsyncKeyState在多线程下不安全,但本项目单线程运行,无需加锁。若你后续想加网络对战,必须重构为事件驱动模型。
3.2 角色状态机:结构体定义与帧间状态迁移
FightGame.cpp中定义的PLAYER结构体是整个战斗逻辑的基石:
// FightGame.cpp typedef struct { int x, y; // 屏幕坐标(像素) int hp; // 当前生命值(0-100) int maxHp; // 最大生命值(固定100) int speed; // 移动速度(像素/帧) int facing; // 朝向:-1=左,1=右 bool isAttacking; // 是否处于攻击动画中 int attackTimer; // 攻击冷却计时器(帧数) int hitStun; // 被击中僵直帧数(0=未僵直) } PLAYER; PLAYER g_Player1 = {100, 300, 100, 100, 1, false, 0, 0}; PLAYER g_Player2 = {500, 300, 100, 100, -1, false, 0, 0};UpdatePlayerState()函数实现状态迁移,以P1按J键为例:
// GameLogic.cpp void UpdatePlayerState(PLAYER* p, int input) { if (p->hitStun > 0) { // 僵直中,跳过所有输入 p->hitStun--; return; } if (input == INPUT_ATTACK && p->attackTimer == 0) { p->isAttacking = true; p->attackTimer = 15; // 攻击动画持续15帧(约0.5秒) // 此处应触发碰撞检测,见3.3节 } if (p->isAttacking) { p->attackTimer--; if (p->attackTimer == 0) { p->isAttacking = false; } } }这里暴露了一个经典设计权衡:攻击判定帧(Hitbox)与动画帧分离。当前代码中,isAttacking=true期间任何碰撞都会造成伤害,但实际格斗游戏要求“第5帧到第12帧才有攻击判定”。你要改,就在if (p->isAttacking)块内加帧计数器,比如attackFrame++,再用if (attackFrame >=5 && attackFrame <=12)包裹伤害逻辑。
3.3 碰撞检测与伤害计算:矩形包围盒与帧同步陷阱
伤害不是靠“碰到就扣血”,而是严格的帧同步判定。GameLogic.cpp中CheckCollision()函数只在攻击方attackTimer==10时执行(即攻击动画中段):
// GameLogic.cpp void CheckCollision() { // 仅当P1在攻击且处于判定帧时检查 if (g_Player1.isAttacking && g_Player1.attackTimer == 10) { RECT p1Box = {g_Player1.x, g_Player1.y, g_Player1.x + 40, g_Player1.y + 60}; // P1攻击范围矩形 RECT p2Box = {g_Player2.x, g_Player2.y, g_Player2.x + 40, g_Player2.y + 60}; // P2身体矩形 if (IntersectRect(&g_CollisionRect, &p1Box, &p2Box)) { // 命中!扣血并施加僵直 g_Player2.hp -= 15; g_Player2.hitStun = 30; // 僵直30帧(1秒) PlaySound(MAKEINTRESOURCE(IDR_SOUND_HIT), g_hInstance, SND_RESOURCE | SND_ASYNC); } } // P2同理,省略... }参数说明:
IntersectRect是Win32 API,高效计算两矩形交集;SND_ASYNC标志让音效异步播放,避免PlaySound阻塞主线程;g_CollisionRect是全局变量,存储碰撞区域,可用于后续特效定位(如在交集中心画爆炸粒子)。
避坑:初学者常把碰撞检测放在
UpdatePlayerState里,导致“攻击帧未到就提前判定”。必须严格限定在特定帧,否则会出现“按键瞬间就打中”的玄学体验。
4. 常见问题排查:编译失败、黑屏、输入无响应的血泪经验
4.1 现象:VC++6.0编译报错“fatal error C1083: Cannot open include file: 'ddraw.h'”
原因:VC6默认不安装DirectX SDK,而项目中Graphics.cpp引用了<ddraw.h>(尽管实际未使用DirectDraw,只是历史残留头文件)。
解决:
- 下载DirectX 7 SDK(微软存档镜像),安装时选择“Header Files”和“Libraries”;
- 在VC6中:Tools → Options → Directories → Show directories for: “Include files”,添加SDK的
Include路径(如C:\DXSDK\Include); - 更简单方案:直接注释掉
Graphics.cpp中#include <ddraw.h>这一行——项目实际用GDI绘图,此头文件纯属冗余。
4.2 现象:程序运行后窗口全黑,无角色、无血条
原因:双缓冲DC创建失败,g_hdcBuffer为空,导致所有BitBlt操作无效。
解决:
- 检查
Graphics.cpp中InitGraphics()函数,确认CreateCompatibleDC(NULL)返回值是否为NULL; - 若为
NULL,大概率是窗口客户区宽度/高度为0——在CreateWindow后立即调用GetClientRect,但此时窗口尚未完成布局; - 修复:在
WinMain消息循环前插入ShowWindow(hWnd, SW_SHOW)和UpdateWindow(hWnd),确保窗口已初始化; - 或更稳妥:将
InitGraphics()调用从WinMain开头移到WM_CREATE消息处理中(修改WndProc)。
4.3 现象:键盘输入完全无响应,WASD/方向键均无效
原因:PollInput()未被调用,或g_KeyState数组未正确初始化。
排查步骤:
- 在
WinMain循环内PollInput()调用后加断点,确认函数是否执行; - 检查
InputHandler.cpp是否被加入工程:VC6中右键工程 → Settings → C/C++ → Category: “General”,确认InputHandler.cpp在文件列表中; - 致命陷阱:
g_KeyState定义在InputHandler.cpp中,但FightGame.cpp中未声明extern int g_KeyState[256];——导致FightGame.cpp读取的是未初始化的栈内存。
解决:在FightGame.cpp顶部添加extern int g_KeyState[256];声明。
4.4 现象:角色移动时出现“瞬移”或“卡顿”,血条更新不平滑
原因:帧率不稳定,timeGetTime()返回值受系统负载影响。
验证:在RenderFrame()中添加char buf[32]; wsprintf(buf, "FPS:%d", 1000/(curTime-lastTime)); TextOut(g_hdcBuffer, 10,10, buf, strlen(buf));显示实时FPS;
解决:
- 若FPS在20-40间剧烈波动,关闭杀毒软件和后台更新;
- 若稳定在30但仍有卡顿,检查
UpdateGameLogic()中是否有耗时操作(如Sleep(10)误留); - 终极方案:将
FRAME_TIME从固定33改为动态计算,记录最近10帧的curTime-lastTime平均值,用其作为新阈值。
5. 图形与音效资源集成:位图加载、透明色处理与WAV播放控制
5.1 位图资源加载:从RC文件到GDI兼容位图
FightGame.rc中定义了两个玩家位图:
IDR_PLAYER1 BITMAP "res\\player1.bmp" IDR_PLAYER2 BITMAP "res\\player2.bmp"Graphics.cpp中LoadBitmapResource()函数负责加载:
// Graphics.cpp HBITMAP LoadBitmapResource(UINT nID) { HBITMAP hBmp = (HBITMAP)LoadImage(g_hInstance, MAKEINTRESOURCE(nID), IMAGE_BITMAP, 0, 0, LR_CREATEDIBSECTION); if (!hBmp) { // 加载失败,返回空位图(避免崩溃) return CreateCompatibleBitmap(g_hdcBuffer, 1, 1); } return hBmp; }关键点:
LR_CREATEDIBSECTION标志确保创建设备无关位图(DIB),支持Alpha通道(虽然本项目未用);LoadImage比LoadBitmap更健壮,能处理多种格式;- 失败时返回1x1位图,防止后续
SelectObject崩溃。
5.2 透明色抠图:GDI的“魔色”(Magic Color)实现
项目位图使用纯粉色(RGB:255,0,255)作为透明色。DrawSprite()函数中实现抠图:
void DrawSprite(HDC hdc, HBITMAP hBmp, int x, int y) { BITMAP bmp; GetObject(hBmp, sizeof(BITMAP), &bmp); HDC hdcMem = CreateCompatibleDC(hdc); HBITMAP hOld = (HBITMAP)SelectObject(hdcMem, hBmp); // 创建掩码位图(黑色背景+白色前景) HDC hdcMask = CreateCompatibleDC(hdc); HBITMAP hBmpMask = CreateCompatibleBitmap(hdc, bmp.bmWidth, bmp.bmHeight); SelectObject(hdcMask, hBmpMask); SetBkColor(hdcMem, RGB(255,0,255)); // 设置透明色为粉色 BitBlt(hdcMask, 0,0, bmp.bmWidth,bmp.bmHeight, hdcMem, 0,0, SRCCOPY); // 两次BitBlt实现透明贴图 BitBlt(hdc, x,y, bmp.bmWidth,bmp.bmHeight, hdcMask, 0,0, SRCAND); // 掩码与目标图“与” BitBlt(hdc, x,y, bmp.bmWidth,bmp.bmHeight, hdcMem, 0,0, SRCPAINT); // 源图与目标图“或” DeleteDC(hdcMask); DeleteDC(hdcMem); DeleteObject(hBmpMask); }这就是经典的GDI透明贴图四步法:
- 创建掩码DC,将源位图中粉色区域设为黑色(0),其余为白色(1);
SRCAND:目标DC与掩码“与”,保留原背景中非粉色区域;SRCPAINT:源位图与目标DC“或”,将非粉色像素叠加;- 结果:粉色区域完全透明,其他像素正常显示。
提示:若你替换位图为PNG,需改用GDI+或第三方库(如stb_image),GDI原生不支持PNG。
5.3 音效播放:PlaySound的同步与异步陷阱
PlaySound()调用看似简单,但有两个隐藏雷区:
SND_SYNC(同步)会导致PlaySound阻塞直到音效播完,若WAV长达2秒,游戏将卡死;SND_ASYNC(异步)虽不卡主线程,但若连续快速点击攻击键,音效会重叠播放,变成“嗡嗡”噪音。
解决方案:在GameLogic.cpp中添加音效队列:
// 全局音效队列(简化版) struct SOUND_QUEUE { UINT id; // 资源ID,如IDR_SOUND_HIT int priority; // 优先级,攻击音效=10,移动音效=1 } g_SoundQueue[8]; int g_SoundCount = 0; void QueueSound(UINT id, int priority) { if (g_SoundCount < 8) { g_SoundQueue[g_SoundCount].id = id; g_SoundQueue[g_SoundCount].priority = priority; g_SoundCount++; } } // 在RenderFrame()末尾调用 void PlayQueuedSounds() { if (g_SoundCount > 0) { // 播放最高优先级音效 PlaySound(MAKEINTRESOURCE(g_SoundQueue[0].id), g_hInstance, SND_RESOURCE | SND_ASYNC | SND_NOSTOP); // 移除已播放项 for (int i = 0; i < g_SoundCount-1; i++) { g_SoundQueue[i] = g_SoundQueue[i+1]; } g_SoundCount--; } }这样既保证音效不卡顿,又避免重叠——每次只播一个,且优先播放高优先级音效。
6. 二次开发实战:从单机对打到本地双人联机的改造路径
6.1 改造目标:让两个玩家能用不同键盘(或同一键盘不同键位)独立操作
当前代码中,InputHandler.cpp的g_KeyState数组同时服务于P1和P2,但输入逻辑耦合在UpdatePlayerState中。要实现真正独立的双人操作,必须解耦输入源。
第一步:分离输入设备标识
修改InputHandler.cpp,增加输入源枚举:
// InputHandler.cpp typedef enum { INPUT_SOURCE_P1, INPUT_SOURCE_P2 } INPUT_SOURCE; // 全局双输入状态(原g_KeyState变为二维数组) int g_KeyState[2][256] = {{0}}; // [0]=P1, [1]=P2 void PollInputForSource(INPUT_SOURCE src) { int idx = (src == INPUT_SOURCE_P1) ? 0 : 1; for (int i = 0; i < 256; i++) { SHORT state = GetAsyncKeyState(i); if (state & 0x8000) { if (g_KeyState[idx][i] == 0) g_KeyState[idx][i] = 2; else g_KeyState[idx][i] = 1; } else { g_KeyState[idx][i] = 0; } } }第二步:重构输入轮询时机
在WinMain循环中,不再统一PollInput(),而是分时调用:
// FightGame.cpp WinMain循环内 if (g_bRunning) { // ...PeekMessage省略... if (curTime - lastTime >= FRAME_TIME) { // 分别轮询P1和P2输入(模拟双键盘) PollInputForSource(INPUT_SOURCE_P1); PollInputForSource(INPUT_SOURCE_P2); UpdateGameLogic(); // 此函数内部需适配双输入源 RenderFrame(); lastTime = curTime; } }第三步:重写UpdateGameLogic,支持双输入源GameLogic.cpp中UpdatePlayerState函数签名升级:
// GameLogic.cpp void UpdatePlayerState(PLAYER* p, int input, INPUT_SOURCE src) { // src用于区分P1/P2,可在此扩展网络输入源 int* keyState = g_KeyState[src == INPUT_SOURCE_P1 ? 0 : 1]; // P1控制:WASD移动,J攻击 if (src == INPUT_SOURCE_P1) { if (keyState[KEY_W] == 2) p->y -= p->speed; if (keyState[KEY_S] == 2) p->y += p->speed; if (keyState[KEY_A] == 2) { p->x -= p->speed; p->facing = -1; } if (keyState[KEY_D] == 2) { p->x += p->speed; p->facing = 1; } if (keyState[KEY_J] == 2 && p->attackTimer == 0) { p->isAttacking = true; p->attackTimer = 15; } } // P2控制:方向键移动,K攻击(此处可自由映射) else { if (keyState[VK_UP] == 2) p->y -= p->speed; if (keyState[VK_DOWN] == 2) p->y += p->speed; if (keyState[VK_LEFT] == 2) { p->x -= p->speed; p->facing = -1; } if (keyState[VK_RIGHT] == 2) { p->x += p->speed; p->facing = 1; } if (keyState[KEY_K] == 2 && p->attackTimer == 0) { p->isAttacking = true; p->attackTimer = 15; } } // ...僵直、攻击计时等通用逻辑保持不变... }6.2 验证方法:用记事本快速测试输入隔离性
在改造过程中,最怕P1按键影响P2状态。我习惯用以下三步验证:
- 在
PollInputForSource()中,对P1输入添加日志:if (src==INPUT_SOURCE_P1 && keyState[KEY_W]==2) OutputDebugString("P1 W pressed\n");; - 启动程序,用Visual Studio附加进程,打开“输出”窗口(Debug → Windows → Output);
- 单独按W键,确认只输出
P1 W pressed;再按方向键,确认无输出——证明P2输入未污染P1状态。
这比看游戏表现更早发现问题,因为画面延迟可能掩盖逻辑错误。
6.3 进阶技巧:添加“暂停菜单”与“胜利动画”的最小化实现
很多开发者卡在“加功能就崩”,其实只需遵循一个原则:所有新UI必须复用现有GDI绘图函数,不新增DC或消息循环。
以暂停菜单为例,只需在RenderFrame()末尾插入:
// RenderFrame() 函数末尾 if (g_bPaused) { // 绘制半透明遮罩 HBRUSH hBrush = CreateSolidBrush(RGB(0,0,0)); FillRect(g_hdcBuffer, &g_ClientRect, hBrush); DeleteObject(hBrush); // 绘制文字(复用现有TextOut) SetTextColor(g_hdcBuffer, RGB(255,255,255)); SetBkMode(g_hdcBuffer, TRANSPARENT); TextOut(g_hdcBuffer, g_ClientRect.right/2-50, g_ClientRect.bottom/2-20, "PAUSED - Press ESC to continue", 30); }g_bPaused在WndProc中监听ESC键:
// WndProc 中 case WM_KEYDOWN: if (wParam == VK_ESCAPE) { g_bPaused = !g_bPaused; return 0; } break;没有新窗口、没有新线程、不改消息循环——这就是VC6时代“轻量级扩展”的精髓。
从那以后我每次给学生讲Win32游戏开发,都强制走一遍“先加暂停菜单,再加胜利动画”的流程。因为这两处改造会逼你直面三个核心问题:全局状态管理(g_bPaused)、绘图时机控制(RenderFrame位置)、消息路由(WndProc拦截)。只要它们稳了,后续加网络、加AI、加存档,不过是往同一个骨架上添肉。希望帮到你。
本文还有配套的精品资源,点击获取