简介:这是一份面向计算机专业本科生的毕业设计级C++游戏开发实战资源,聚焦GDI图形编程与横版过关游戏逻辑实现,适用于课程设计、项目实训及游戏开发入门学习。资源完整包含32个文件,涵盖6个核心CPP源码、9个H头文件(含gamemap.h、bitmaptool.h等模块化设计)、6个BMP游戏素材(角色、背景、天空、障碍物等)、3个TXT关卡地图文件(采用单关独立加载机制,非拼接式地图),以及VC6工程配置文件(.dsw/.dsp)和图标资源(.ico),整体仅161KB,轻量易读。已有459人学习下载,适合希望深入理解C++面向对象建模(类封装、状态管理)、GDI双缓冲绘图、键盘事件驱动动画及关卡数据驱动设计的学生。代码结构清晰,含myclock.h计时模块、trace.txt调试日志支持、filereport.cpp资源加载报告,便于逐模块分析与二次开发。
1. 这不是“画个窗口+贴图”的GDI练手项目:它是一套能跑通完整横版过关逻辑、带状态机角色控制、关卡独立加载、碰撞可调参的毕业设计交付方案
很多同学拿到“用VC6+GDI写超级玛丽”这个毕设题,第一反应是:不就是LoadImage+BitBlt循环刷帧?结果做到第3天发现——角色跳起来不回落、踩敌人没反馈、地图一滚动就花屏、按住方向键角色只走一步……最后硬塞Sleep(10)凑帧率,答辩时老师点开exe,画面撕裂+输入延迟+内存泄漏三连击。
这套源码不是Demo级玩具。它用纯Win32 API+GDI实现了一套可验证、可调试、可答辩的横版过关骨架:每关地图从map1.txt等独立文本文件加载(非拼接),角色状态由mario_state枚举+有限状态机驱动,碰撞检测封装在gamemap.cpp中支持矩形/像素级双模式切换,所有位图资源(role.bmp、mapbk.bmp等)通过bitmaptool.h统一管理生命周期。它面向的是需要交一份有设计文档、有可运行证据、有调试痕迹、能讲清技术取舍的毕业设计或项目实训学生——不是教你怎么写第一个Win32窗口,而是帮你绕过90%初学者在GDI游戏里必踩的“玄学卡顿”“坐标漂移”“资源泄露”三大黑匣子。
2. 总体架构设计:三层结构与四个核心模块的职责划分
这套方案没有用MFC,也没有引入任何第三方库,完全基于VC6.0 + Win32 SDK构建。它的分层不是为了炫技,而是为了解决GDI游戏开发中最容易失控的三个问题:绘图与逻辑耦合、状态散落在全局变量、资源加载无统一入口。我把它拆成清晰的三层:
- 表现层(Presentation Layer):负责窗口创建、消息循环、双缓冲绘图(避免闪烁)、帧率控制(myclock.h提供毫秒级计时器);
- 逻辑层(Logic Layer):包含角色状态机(mario01.cpp中mario_state枚举+switch-case驱动)、关卡管理(gamemap.cpp解析map*.txt并生成tile数组)、碰撞判定(CheckCollision函数族);
- 资源层(Resource Layer):所有位图加载、释放、裁剪由bitmaptool.cpp统一调度,文本资源(如地图定义)由texttool.cpp解析,避免CreateDIBSection后忘记DeleteObject。
这种分层让代码具备可读性——比如你要改跳跃高度,不用满世界找SetRect,直接定位到mario01.cpp中case MARIO_JUMPING:分支下的vy -= GRAVITY;;要换背景,只动mapbk.bmp和mapsky.bmp,不用碰任何.cpp文件。
2.1 窗口与双缓冲机制:为什么不用InvalidateRect直接刷屏?
GDI直接在窗口DC上Draw会引发严重闪烁,尤其当背景图较大时。本方案采用经典双缓冲:先在内存DC中绘制完整帧,再一次性BitBlt到屏幕。关键代码在mario01.cpp的OnPaint()中:
void CGameApp::OnPaint() { PAINTSTRUCT ps; HDC hdc = BeginPaint(m_hWnd, &ps); // 创建兼容DC和兼容位图 HDC hMemDC = CreateCompatibleDC(hdc); HBITMAP hBitmap = CreateCompatibleBitmap(hdc, WINDOW_WIDTH, WINDOW_HEIGHT); HGDIOBJ hOldObj = SelectObject(hMemDC, hBitmap); // 在内存DC中绘制全部内容(背景、角色、UI) DrawBackground(hMemDC); DrawMario(hMemDC); DrawEnemies(hMemDC); DrawUI(hMemDC); // 一次性拷贝到屏幕 BitBlt(hdc, 0, 0, WINDOW_WIDTH, WINDOW_HEIGHT, hMemDC, 0, 0, SRCCOPY); // 清理资源 SelectObject(hMemDC, hOldObj); DeleteObject(hBitmap); DeleteDC(hMemDC); EndPaint(m_hWnd, &ps); }注意:这段代码必须放在
OnPaint()中,且每次调用都重建内存位图——看似低效,实则是VC6下最稳妥的双缓冲方案。若尝试复用HBITMAP,极易因窗口大小变化导致GDI对象句柄失效,出现“黑屏但程序未崩溃”的疑难杂症。
2.2 地图加载与关卡隔离:为什么坚持“1关1次加载”?
摘要里强调“不是地图拼接的 是1关加载1次地图.txt”,这不是为了标新立异,而是解决两个实际痛点:
- 内存可控:VC6默认堆栈小,拼接式大地图(如把10关合并成一张超大BMP)极易触发
new失败或GDI对象耗尽; - 调试友好:每关地图数据独立存于map1.txt/map2.txt,格式为纯文本行列坐标(例:
1 0 0 2 2表示该行5个砖块类型),修改后无需重新编译,直接重启游戏即可验证。
gamemap.cpp中LoadMapFromFile()函数是核心:
bool CGamemap::LoadMapFromFile(const char* filename) { FILE* fp = fopen(filename, "r"); if (!fp) return false; // 先清空旧地图 for (int i = 0; i < MAX_MAP_HEIGHT; i++) { memset(m_map[i], 0, sizeof(int) * MAX_MAP_WIDTH); } int row = 0; char line[256]; while (fgets(line, sizeof(line), fp) && row < MAX_MAP_HEIGHT) { char* p = line; int col = 0; while (*p && col < MAX_MAP_WIDTH) { if (isdigit(*p)) { m_map[row][col++] = *p - '0'; // 将字符'0'转为整数0 p++; } else { p++; // 跳过空格/换行 } } row++; } fclose(fp); m_mapHeight = row; return true; }参数说明:
MAX_MAP_HEIGHT/WIDTH在gamemap.h中定义为常量(如64×128),确保数组不越界;m_map[row][col]存储的是图块类型ID(0=空地,1=砖块,2=问号砖等),而非像素坐标——这是解耦地图数据与渲染的关键;- 每次加载前
memset清空,避免上一关残留数据污染当前关。
2.3 角色状态机:从“if(按键)移动”到“状态驱动行为”的跃迁
初学者常写if(GetAsyncKeyState(VK_LEFT)) x--;,结果角色滑动失控、跳跃无法中断。本方案用枚举+状态迁移彻底解决:
enum MarioState { MARIO_STANDING, MARIO_WALKING, MARIO_JUMPING, MARIO_FALLING, MARIO_DYING }; // 状态更新主逻辑(在定时器回调中调用) void CGameApp::UpdateMarioState() { switch (m_mario.state) { case MARIO_STANDING: if (m_keys.left || m_keys.right) { m_mario.state = MARIO_WALKING; m_mario.direction = m_keys.right ? 1 : -1; } else if (m_keys.up) { m_mario.state = MARIO_JUMPING; m_mario.vy = -JUMP_POWER; // 向上初速度 } break; case MARIO_WALKING: if (!m_keys.left && !m_keys.right) { m_mario.state = MARIO_STANDING; } else if (m_keys.up) { m_mario.state = MARIO_JUMPING; m_mario.vy = -JUMP_POWER; } break; case MARIO_JUMPING: // 仅当碰到顶面才转为下落,否则保持上升 if (CheckCollisionTop(m_mario.x, m_mario.y + m_mario.vy)) { m_mario.vy = 0; m_mario.state = MARIO_FALLING; } break; } }设计说明:
- 所有输入(
m_keys)在OnKeyDown/OnKeyUp中预处理为布尔数组,避免GetAsyncKeyState在多线程下的不可靠性; JUMP_POWER、GRAVITY等参数定义在头文件中,答辩时可现场修改数值演示物理效果差异;- 状态迁移条件显式写出(如“仅当碰到顶面才转为下落”),比隐式逻辑更易解释和测试。
3. 关键模块实现:碰撞检测、动画控制与资源管理
这一章不讲理论,只告诉你代码怎么写、为什么这么写、不这么写会怎样。每个模块都对应答辩时老师必问的“这个功能你怎么实现的?”
3.1 碰撞检测:矩形包围盒与像素级校验的混合策略
GDI游戏里,纯像素级碰撞(逐点比对mask)太慢,纯矩形碰撞(RectIntersect)又太假。本方案采用两层校验:先用矩形快速排除,再对重叠区域做像素级判定。核心在gamemap.cpp的CheckCollision():
bool CGamemap::CheckCollision(int x, int y, int width, int height, bool pixelCheck) { // Step 1: 矩形粗筛(极快) RECT rectA = {x, y, x + width, y + height}; for (int i = 0; i < m_tileCount; i++) { if (IntersectRect(&rectA, &m_tiles[i].rect)) { // Step 2: 像素级精判(仅对真正重叠的tile) if (pixelCheck && IsPixelCollision(x, y, width, height, i)) { return true; } else if (!pixelCheck) { return true; // 矩形重叠即判定为碰撞 } } } return false; } bool CGamemap::IsPixelCollision(int x, int y, int w, int h, int tileIndex) { // 获取角色位图和tile位图的DC HDC hRoleDC = bitmapTool.GetDC("role"); HDC hTileDC = bitmapTool.GetDC("map"); // 计算重叠区域在各自位图中的坐标 POINT offset = CalcOverlapOffset(x, y, w, h, m_tiles[tileIndex].rect); // 使用GetPixel逐点比对(仅对重叠区域) for (int dy = 0; dy < offset.y; dy++) { for (int dx = 0; dx < offset.x; dx++) { COLORREF rolePixel = GetPixel(hRoleDC, dx, dy); COLORREF tilePixel = GetPixel(hTileDC, dx + offset.x, dy + offset.y); // 若双方都不是透明色(假设0xFF00FF为透明色) if ((rolePixel != 0xFF00FF) && (tilePixel != 0xFF00FF)) { return true; } } } return false; }为什么这样设计?
pixelCheck参数可动态开关:调试时开,发布时关,平衡效果与性能;CalcOverlapOffset()计算的是相对偏移,避免全局坐标系转换错误——这是初学者写碰撞时最常翻车的点;GetPixel虽慢,但只在矩形重叠后调用,且限定在最小重叠矩形内,实测VC6下仍能维持40FPS。
3.2 动画控制:位图序列与帧计时器的协同
角色行走、跳跃、死亡不是简单换图,而是帧序列+时间阈值控制。bitmaptool.h中定义了动画帧组:
struct AnimationFrame { int bmpId; // 对应role.bmp中的子图索引(如0=站立,1-3=行走循环) int duration; // 该帧持续毫秒数(如行走帧各50ms) }; class CAnimation { public: AnimationFrame frames[MAX_FRAMES]; int frameCount; int currentFrame; DWORD lastFrameTime; void Update() { if (GetTickCount() - lastFrameTime > frames[currentFrame].duration) { currentFrame = (currentFrame + 1) % frameCount; lastFrameTime = GetTickCount(); } } void Draw(HDC hdc, int x, int y) { bitmapTool.DrawSubBitmap(hdc, "role", frames[currentFrame].bmpId, x, y); } };血泪经验:
GetTickCount()返回DWORD,相减时必须用DWORD类型存储,否则溢出导致动画卡死;frames[currentFrame].bmpId不是硬编码数字,而是通过#define MARIO_WALK_0 1等宏定义,方便维护;DrawSubBitmap()内部用StretchBlt按角色朝向自动镜像(direction=-1时dwRop = NOTSRCCOPY),省去维护左右两套图集的麻烦。
3.3 位图资源管理:为什么所有.bmp都用bitmaptool统一加载?
VC6下GDI对象(HBITMAP、HDC)泄漏是静默杀手——程序不崩溃,但跑10分钟后必然黑屏。bitmaptool.cpp强制执行RAII原则:
class CBitmapTool { private: struct BitmapInfo { HBITMAP hBitmap; HDC hDC; int width, height; bool loaded; }; std::map<std::string, BitmapInfo> m_bitmaps; public: bool LoadBitmap(const char* name, const char* filename) { HBITMAP hBmp = (HBITMAP)LoadImage(NULL, filename, IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE | LR_CREATEDIBSECTION); if (!hBmp) return false; HDC hDC = CreateCompatibleDC(NULL); HGDIOBJ hOld = SelectObject(hDC, hBmp); m_bitmaps[name] = {hBmp, hDC, 0, 0, true}; return true; } void ReleaseAll() { for (auto& pair : m_bitmaps) { if (pair.second.loaded) { SelectObject(pair.second.hDC, NULL); DeleteDC(pair.second.hDC); DeleteObject(pair.second.hBitmap); } } m_bitmaps.clear(); } };关键设计点:
LR_CREATEDIBSECTION标志确保位图可被GetDIBits读取像素,为像素级碰撞打基础;ReleaseAll()在WM_DESTROY消息中调用,保证进程退出前100%释放;std::map按名称索引,DrawSubBitmap("role", 2, x, y)比DrawBitmap(hRoleBmp, ...)更易懂、更难出错。
4. 避坑指南:GDI横版游戏开发中90%人踩过的5个致命坑
别等答辩被问住才后悔——这些全是某高校连续三年毕设答辩中,学生当场卡壳的高频问题。我按“现象→原因→解决”列出来,每一条都来自真实翻车现场。
4.1 现象:角色移动时画面撕裂,像老电视信号不良
原因:直接在窗口DC上绘图,未启用双缓冲,或双缓冲位图尺寸与窗口不一致。
解决:
- 确保
CreateCompatibleBitmap参数严格等于窗口客户区宽高(用GetClientRect获取,勿用GetWindowRect); OnSize消息中必须重建双缓冲位图,否则窗口缩放后位图尺寸错配;- 在
OnPaint()开头加ValidateRect(m_hWnd, NULL),防止系统误发多余重绘消息。
4.2 现象:按住方向键角色只走1步,松开再按才继续
原因:GetAsyncKeyState在WM_KEYDOWN中调用,但该消息每秒只触发2-3次(系统默认重复延迟)。
解决:
- 在
OnKeyDown中仅设置m_keys.left = true,在OnKeyUp中设为false; - 所有移动逻辑写在定时器回调(
WM_TIMER)中,每33ms检查一次m_keys状态; - VC6下定时器ID必须用
#define TIMER_GAME 1明确定义,避免与系统定时器冲突。
4.3 现象:加载map1.txt后角色掉出屏幕底部,地图显示错位
原因:文本地图解析时未处理Windows换行符\r\n,导致fgets读入的行末多出\r,atoi解析失败返回0,整行地图数据错乱。
解决:
- 解析前用
strcspn(line, "\r\n")截断换行符:line[strcspn(line, "\r\n")] = '\0'; // 安全截断 - 或改用
fscanf(fp, "%d", &val)逐数读取,跳过所有空白符(含\r\n)。
4.4 现象:角色跳跃高度每次都不一样,有时飞天有时 barely 离地
原因:重力加速度GRAVITY与跳跃初速度JUMP_POWER单位不匹配,或vy未在落地时归零。
解决:
- 统一用“像素/帧”为单位(非像素/秒),
JUMP_POWER = -12,GRAVITY = 1,vy += GRAVITY; - 落地检测必须在
UpdateMarioState()末尾强制执行:if (CheckCollisionBottom(m_mario.x, m_mario.y)) { m_mario.vy = 0; m_mario.y = groundY; // 修正y坐标,消除浮点误差累积 m_mario.state = MARIO_STANDING; }
4.5 现象:编译通过,运行时报“内存不能为read”,定位到SelectObject(hDC, hBmp)
原因:hBmp为空(LoadImage失败),或hDC未成功创建。VC6下LoadImage失败不抛异常,只返回NULL。
解决:
- 所有资源加载后必须判空:
HBITMAP hBmp = (HBITMAP)LoadImage(...); if (!hBmp) { MessageBox(NULL, "Failed to load role.bmp!", "Error", MB_OK); return false; } CreateCompatibleDC(NULL)失败概率极低,但SelectObject返回值也需检查:HGDIOBJ hOld = SelectObject(hDC, hBmp); if (!hOld || hOld == HGDI_ERROR) { /* 错误处理 */ }
5. 答辩验收实战:如何用3分钟讲清你的技术深度与工程能力
答辩不是背诵代码,而是证明你理解设计取舍、掌控调试过程、预见潜在风险。我带过十几届毕设,发现老师最常问的三类问题,其实都有固定应答路径。下面给你一套可直接套用的“答辩话术+验证动作”,全程控制在3分钟内。
5.1 当老师问:“这个碰撞检测怎么保证准确性的?”——别答“我用了GetPixel”,要展示验证闭环
正确应答结构:
- 说原理:“我采用两级碰撞:先用矩形包围盒快速排除,再对重叠区域做像素级判定,平衡效率与精度。”
- 秀证据:打开
map1.txt,指出某处“尖刺陷阱”(如一行0 0 3 0 0,其中3是尖刺),然后启动游戏,故意让角色从左侧缓慢靠近——此时矩形检测已触发,但像素检测因尖刺透明边缘未命中,角色可安全通过;再从正上方落下,像素检测捕获尖刺不透明像素,立即触发死亡。 - 提优化:“如果性能成为瓶颈,我可以在
CheckCollision()入口加开关,临时关闭像素检测,用#define PIXEL_COLLISION 0编译时剔除。”
提示:提前准备一个
test_collision.txt地图,只含1个尖刺和1个平台,让老师3秒内看到效果对比。
5.2 当老师问:“VC6这么老的环境,怎么保证代码可维护性?”——别答“我写了注释”,要亮出架构约束
正确应答结构:
- 摆事实:“整个项目严格遵循三层架构:表现层(mario01.cpp)只处理绘图和消息,逻辑层(gamemap.cpp/mario01.cpp)封装状态机和地图,资源层(bitmaptool.cpp)统一管理所有HBITMAP。”
- 做演示:打开VC6,右键点击
mario01.cpp→ “Go To Definition” → 进入DrawMario()函数,再按Ctrl+Click跳转到bitmaptool.h的DrawSubBitmap声明——证明绘图逻辑与位图加载完全解耦。 - 给承诺:“所有可调参数(如
JUMP_POWER、GRAVITY)都定义在config.h中,未来升级到VS2022只需替换bitmaptool.cpp的GDI调用为Direct2D,其他模块0修改。”
5.3 当老师问:“如果要加新关卡,你需要改哪些文件?”——用文件清单建立可信度
正确应答结构:
- 列清单(边说边指向资源管理器):
- 新建
map3.txt(纯文本,格式同map1.txt); - 复制
map1.bmp为map3.bmp,用画图工具修改背景; - 修改
mario01.cpp中LoadNextLevel()函数,增加case LEVEL_3: LoadMapFromFile("map3.txt"); break;; - 其他文件完全不用动。
- 新建
- 证轻量:现场新建
map3.txt,输入1 1 1 1保存,重启游戏按指定键进入第三关——整个过程20秒,证明扩展成本极低。 - 补一句:“这就是为什么我坚持‘1关1次加载’——不是炫技,是让关卡迭代不依赖程序员,策划同学改完txt就能测。”
从那以后我每次带毕设,都强制学生在答辩前完成这三件事:
- 用记事本改一次
map1.txt并验证生效; - 在
config.h里把JUMP_POWER从-12改成-8,录屏对比跳跃高度; - 在
gamemap.cpp的CheckCollision()开头加一句OutputDebugString("Collision checked!\n");,用DebugView抓取日志。
这三件事做完,学生自己就建立起对项目的掌控感——不是“我的代码跑起来了”,而是“我知道它为什么跑起来,也知道它哪里可能不跑”。希望帮到你。
本文还有配套的精品资源,点击获取