简介:这是一份面向高校C++课程设计与Windows游戏开发初学者的完整实践项目,主题为“捕鱼达人”,适合正在学习面向对象编程、希望把语法知识落地为可运行图形程序的学生参考。项目围绕Windows API展开,涵盖窗口创建、消息循环与事件响应、GDI图形绘制、鱼群移动与子弹碰撞检测、积分计算、图像与音频资源加载及内存释放等环节,并涉及继承、多态、封装等C++核心特性,可作为课程设计选题或自学练手案例。资源包共36个文件,约7.42MB,包含bmp位图与jpg背景图、ogg与mp3音效、wav音频、h头文件与cpp源码、lib静态库,以及dsp、dsw等工程配置文件和若干txt说明,图片、音频、代码与工程文件分类存放,便于直接编译运行与逐模块阅读。目前已有1307人学习下载,读者可借此理解游戏主循环、碰撞判定与资源管理的实现思路,积累Windows编程与调试优化经验。
1. 从一份“看起来能跑但一改就崩”的课设说起
每年到课程设计季,总有人拿着一个能运行但完全不敢改的 Windows 小游戏来问:捕鱼达人这种题目到底该怎么拆?为什么网上找的代码一编译就报错,改个炮台角度整个画面就闪成幻灯片?这套方案就是冲着这个问题来的——用 C++ 和 Win32 API 从零搭一个捕鱼达人,不依赖任何游戏引擎,纯 Windows 程序设计思路,适合正在做大学课程设计、需要交一份完整可演示项目的同学。它要解决的不是“怎么写出一个游戏”,而是“怎么用课程里学的窗口、消息、绘图、定时器这些知识点,组织出一个结构清晰、能讲清楚、能当场答辩的完整程序”。下面按需求、架构、模块、避坑、验收的顺序,把这份课设从头到尾拆一遍。
2. 需求与选型:为什么用 Win32 而不是游戏引擎
2.1 课程设计到底要交什么
先明确交付物。一份合格的 Windows 程序设计课设,通常要求包含可执行程序、源代码、设计文档三部分,答辩时老师会现场运行并让你指出关键代码。捕鱼达人这个题目的需求可以拆成四层:
第一层是窗口与消息,程序要有一个固定大小的主窗口,能响应鼠标点击、键盘按键和窗口关闭,这是 Win32 程序的基本骨架。第二层是游戏循环,鱼要动、炮台要转、子弹要飞,这些不能靠死循环,得用定时器驱动。第三层是游戏逻辑,包括鱼的生成与游动、子弹发射与碰撞检测、金币扣减与得分累计。第四层是资源与状态,背景图、鱼类图片、炮台图片的加载,以及开始、进行中、结束三种状态的切换。
把这四层想清楚,代码结构基本就定了。很多同学一上来就写WinMain,把所有逻辑塞进WndProc,写到一半发现鱼和子弹的代码缠在一起,改一个崩一个,根子就在需求没分层。
2.2 技术选型:GDI + 定时器 + 双缓冲
选型上,这套方案用纯 Win32 API 加 GDI 绘图,不引入 DirectX 或游戏引擎。原因很实际:课程设计考察的是 Windows 程序设计能力,用引擎反而说不清自己做了什么;而且 GDI 的BitBlt、TransparentBlt足够画 2D 精灵,性能对几十条鱼完全够用。
游戏循环用SetTimer驱动,间隔设为 16 毫秒左右,接近 60 帧。这里有个关键点:定时器消息WM_TIMER的优先级很低,如果窗口在拖动或菜单弹出,消息会被阻塞,游戏会卡顿。所以更稳的做法是在WM_TIMER里只做逻辑更新和重绘请求,重绘统一走InvalidateRect,避免在定时器里直接画。
绘图必须用双缓冲。直接在WM_PAINT里往窗口 DC 上画,鱼一多就会闪,这是 GDI 绘图的经典问题。做法是先在内存 DC 上把整帧画完,再一次性BitBlt到窗口 DC。下面这段是双缓冲的骨架:
// 双缓冲绘制:先在内存DC画完整帧,再一次性拷贝到窗口 void DrawFrame(HWND hwnd, HDC hdcWindow) { HDC hdcMem = CreateCompatibleDC(hdcWindow); HBITMAP hbmMem = CreateCompatibleBitmap(hdcWindow, WIDTH, HEIGHT); HBITMAP hbmOld = (HBITMAP)SelectObject(hdcMem, hbmMem); // 1. 画背景 BitBlt(hdcMem, 0, 0, WIDTH, HEIGHT, g_hdcBg, 0, 0, SRCCOPY); // 2. 画所有鱼(带透明色) for (auto& fish : g_fishes) { TransparentBlt(hdcMem, fish.x, fish.y, fish.w, fish.h, g_hdcFish, fish.frame * fish.w, 0, fish.w, fish.h, RGB(255, 0, 255)); // 品红作透明色 } // 3. 画炮台和子弹 DrawCannon(hdcMem); DrawBullets(hdcMem); // 4. 一次性输出到窗口 BitBlt(hdcWindow, 0, 0, WIDTH, HEIGHT, hdcMem, 0, 0, SRCCOPY); SelectObject(hdcMem, hbmOld); DeleteObject(hbmMem); DeleteDC(hdcMem); }这段代码里CreateCompatibleDC创建的内存 DC 和窗口 DC 兼容,CreateCompatibleBitmap创建同尺寸位图。TransparentBlt是画精灵的关键,最后一个参数指定透明色,图片里这个颜色的像素不会被画出来,鱼才能有镂空边缘。注意TransparentBlt需要链接msimg32.lib,很多同学编译报“无法解析的外部符号”就是漏了这个库。
提示:透明色建议用图片里绝对不会出现的颜色,比如品红 RGB(255,0,255),否则鱼身上某个像素会被误透明,出现“破洞”。
3. 总体架构设计:四个模块与一条消息主线
3.1 模块划分与职责
整个程序按职责切成四个模块,每个模块一个头文件加一个源文件,答辩时能对着文件讲结构:
| 模块 | 文件 | 职责 |
|---|---|---|
| 窗口与消息 | Main.cpp | WinMain、WndProc、定时器、状态机 |
| 游戏对象 | GameObj.h/cpp | 鱼、子弹、炮台的结构体与更新逻辑 |
| 资源管理 | Resource.h/cpp | 位图加载、DC 创建与释放 |
| 碰撞与计分 | Logic.h/cpp | 碰撞检测、金币扣减、得分统计 |
这样分的好处是:改鱼的游动算法只动 GameObj,改碰撞规则只动 Logic,不会牵一发动全身。我一般会要求课设至少做到“一个模块一个文件”,答辩时老师问“碰撞检测在哪”,你能直接翻到 Logic.cpp,印象分就上来了。
3.2 消息主线:定时器驱动状态机
程序的主线是一条消息链:WM_CREATE里加载资源、创建定时器;WM_TIMER里更新所有对象位置、检测碰撞、请求重绘;WM_PAINT里调DrawFrame;WM_LBUTTONDOWN里发射子弹;WM_DESTROY里释放资源、KillTimer、PostQuitMessage。
游戏状态用一个枚举管理:
enum GameState { STATE_READY, // 准备开始,显示开始按钮 STATE_PLAYING, // 进行中 STATE_OVER // 结束,显示得分 };WM_TIMER里先判断状态,只有STATE_PLAYING才更新逻辑。这样开始和结束界面就不会有鱼在动,逻辑干净。状态切换放在鼠标点击里:点开始按钮进PLAYING,金币扣到 0 进OVER。
3.3 对象管理:用 vector 而不是固定数组
鱼和子弹的数量是动态的,用std::vector管理最省事。每条鱼是一个结构体,包含坐标、速度、类型、当前帧、是否存活:
struct Fish { float x, y; // 当前位置 float vx, vy; // 速度分量 int type; // 鱼的种类,决定分值和图片 int frame; // 当前动画帧 bool alive; // 是否存活 };更新时遍历 vector,移动坐标、切换动画帧;碰撞后把alive置 false,下一帧统一erase掉。这里有个血泪经验:不要在遍历过程中直接erase,迭代器会失效,程序直接崩。正确做法是先标记alive=false,遍历完再用remove_if清理:
// 先标记,后清理,避免迭代器失效 for (auto& f : g_fishes) { if (f.alive) { f.x += f.vx; f.y += f.vy; } } g_fishes.erase( std::remove_if(g_fishes.begin(), g_fishes.end(), [](const Fish& f) { return !f.alive; }), g_fishes.end());这个模式在子弹、特效上通用,是整个项目里最值得记住的一行代码。
4. 核心模块实现:鱼群、炮台与碰撞检测
4.1 鱼群生成与游动
鱼从屏幕左右两侧随机生成,向对侧游动,速度按鱼的种类区分:小鱼快、大鱼慢,分值也反过来。生成逻辑放在定时器里,用一个计数器控制频率,比如每 30 帧尝试生成一条,避免鱼挤成一团。
// 每30帧生成一条鱼,随机方向、随机种类 void SpawnFish() { static int counter = 0; if (++counter < 30) return; counter = 0; Fish f; f.type = rand() % 3; // 0/1/2 三种鱼 f.alive = true; f.frame = 0; if (rand() % 2) { // 从左往右 f.x = -60; f.vx = 1.5f + f.type * 0.5f; } else { // 从右往左 f.x = WIDTH; f.vx = -(1.5f + f.type * 0.5f); } f.y = 60 + rand() % (HEIGHT - 160); f.vy = 0; g_fishes.push_back(f); }f.type同时决定图片区域和分值,画的时候用type * 帧宽定位到精灵图对应行。动画帧每几帧切换一次,用frame = (frame + 1) % 4循环,鱼就有摆尾效果。
4.2 炮台旋转与子弹发射
炮台固定在底部中央,鼠标移动时炮台朝向鼠标方向旋转。旋转角度用atan2算:
// 鼠标移动时更新炮台角度 case WM_MOUSEMOVE: { int mx = GET_X_LPARAM(lParam); int my = GET_Y_LPARAM(lParam); // 炮台中心到鼠标的向量,算出角度 g_cannonAngle = atan2((float)(my - CANNON_Y), (float)(mx - CANNON_X)); break; }atan2返回弧度,画炮台时用PlgBlt或预旋转的精灵图。课设里更简单的做法是准备 8 张不同角度的炮台图,按角度区间选图,省去旋转计算,答辩也讲得清楚。
点击发射子弹,子弹从炮口位置沿炮台角度飞出,速度固定。子弹结构体比鱼简单,只有坐标、速度、存活标志。发射时扣金币,金币为 0 时切到结束状态。
4.3 碰撞检测:圆检测而不是矩形检测
鱼和子弹都是不规则形状,用矩形包围盒检测会误判。用圆检测更准:把鱼和子弹都当成圆,比较圆心距离和半径之和。
// 圆碰撞检测:距离平方小于半径和平方即碰撞 bool CheckCollision(const Bullet& b, const Fish& f) { float dx = b.x - f.x; float dy = b.y - f.y; float rSum = b.radius + f.radius; return (dx * dx + dy * dy) < (rSum * rSum); }用距离平方比较,省掉开方运算,性能更好。碰撞后鱼标记死亡、加分、子弹标记死亡。分值按鱼的种类给,小鱼 1 分、中鱼 3 分、大鱼 5 分,金币扣减按子弹成本算,形成“打小鱼亏、打大鱼赚”的简单经济循环,答辩时这是个能讲的点。
注意:碰撞检测放在逻辑更新之后、重绘之前,顺序错了会出现“子弹穿过鱼才判定”的视觉延迟。
5. 避坑与常见问题排查
5.1 画面闪烁严重
现象是鱼一多整个窗口闪成一片。原因是直接在窗口 DC 上逐条画鱼,每次绘制都刷新屏幕。解决就是前面说的双缓冲:所有绘制在内存 DC 完成,最后BitBlt一次。另外WM_ERASEBKGND要返回非零,阻止系统擦背景,否则双缓冲也白搭。
5.2 编译报无法解析的外部符号
现象是链接阶段报TransparentBlt或AlphaBlend找不到。原因是这两个函数在msimg32.lib里,默认不链接。解决是在项目设置里加msimg32.lib,或者代码里加#pragma comment(lib, "msimg32.lib")。
5.3 定时器消息被阻塞导致卡顿
现象是拖动窗口或点菜单时游戏停住。原因是WM_TIMER是低优先级消息,被模态操作阻塞。解决是把游戏逻辑和绘制分离,逻辑更新用定时器,绘制用InvalidateRect请求,不要指望定时器精确。更彻底的做法是用timeGetTime算时间差做补偿,但课设里分离逻辑和绘制已经够用。
5.4 资源泄漏导致程序越跑越卡
现象是玩几分钟后内存上涨、画面变慢。原因是每次WM_PAINT都CreateCompatibleDC和CreateCompatibleBitmap,用完没释放。解决是内存 DC 和位图在WM_CREATE里创建一次,全局保存,WM_DESTROY里统一释放,不要每帧创建。
5.5 图片加载失败但程序不报错
现象是鱼画不出来,窗口一片空白。原因是LoadImage路径不对或返回 NULL,代码没检查。解决是加载后判断句柄是否为 NULL,失败时用MessageBox提示路径,方便定位。资源路径建议用相对路径,并把图片放在 exe 同目录,避免答辩换机器就找不到图。
6. 验收与答辩:怎么把这份课设讲明白
验收分两部分:程序演示和代码讲解。演示时按“开始→打鱼→金币耗尽→结束→重开”走一遍完整流程,确保状态切换正常、分数和金币显示正确、没有崩溃。老师通常会让你连续打几分钟,看有没有内存泄漏和卡顿,所以前面说的资源释放一定要做扎实。
代码讲解准备三个点。第一,指着WndProc讲消息映射,说明WM_CREATE、WM_TIMER、WM_PAINT、WM_LBUTTONDOWN各自做什么,这是 Windows 程序设计的核心考点。第二,指着DrawFrame讲双缓冲,说明为什么要内存 DC,这是区分“会写”和“写得好”的分水岭。第三,指着碰撞检测讲圆检测和距离平方优化,说明为什么不用矩形,这是能体现思考深度的细节。
文档部分,设计文档按“需求分析→总体设计→详细设计→测试”四段写,把模块表、状态机图、关键流程图放进去。测试部分列一个表,把正常流程、边界情况(金币为 0、鱼全部游出屏幕、连续快速点击)都覆盖到,显得严谨。
| 验收项 | 检查方法 | 合格标准 |
|---|---|---|
| 窗口与消息 | 运行程序,操作窗口 | 响应正常,无卡死 |
| 游戏循环 | 观察鱼和子弹运动 | 流畅,无明显卡顿 |
| 碰撞检测 | 瞄准鱼射击 | 命中判定准确 |
| 状态切换 | 金币耗尽 | 正确进入结束界面 |
| 资源释放 | 任务管理器观察内存 | 长时间运行内存稳定 |
答辩时有个小技巧:提前把关键代码行号记在纸上,老师问哪块你能立刻翻到,比现场找快得多。我一般会让学生把DrawFrame、CheckCollision、SpawnFish三个函数的行号背下来,这三个覆盖了绘图、逻辑、生成三条主线,问什么都能接上。
从那以后我每次带课设,都强制要求先画模块表再写代码,结构清楚了,改需求不慌,答辩也有话讲。希望这份拆解能帮到你。
本文还有配套的精品资源,点击获取