简介:这是一套基于视窗平台的C++课程设计项目,主题为“捕鱼达人”小游戏,面向需要完成Windows编程作业或初次尝试游戏开发的学生,可帮助理解面向对象语法、图形界面构建与事件驱动机制在实战中的配合。资源包共36个文件,压缩后大小7.42MB,内容按源码、图片、音频、文档分类存放:位图资源用于绘制游戏背景、鱼群与按钮,音频文件提供背景音乐和音效,C++源文件及头文件是主要学习对象,另有工程文件和文本说明辅助阅读。目前已有1307人学习下载。通过分析项目代码,读者能接触窗口创建、消息循环、图形设备接口绘图、鼠标键盘输入、游戏循环与碰撞检测等关键实现,同时了解静态链接库的使用以及资源加载、内存释放等注意事项;项目中音效播放与画面同步的处理思路也值得借鉴,整体适合作为大学课程设计或自学进阶的实践参考,有助于将理论转化为可运行的完整小游戏。
1. 捕鱼达人课设资源拆解:这份 C++ 小游戏源码能帮你少踩两周坑
第一次在课程设计列表里看到“Windows程序设计小游戏(捕鱼达人)”这个题目,很多人的反应是:图形界面都不熟,还要做动画,真的能交差吗?实际上 C++ 大学课程设计项目往往不要求商业级视觉,只考察 Win32 消息循环、GDI 绘图、链表或数组管理动态对象这三件事。我拆过的这份源码,正好把这三个点自洽地串在一起,代码量控制在 1500 行上下,却完整覆盖了鱼群游动、鼠标捕捉、计分和双缓冲显示。适合学完 C++ 但第一次接触 Windows 图形程序的你,也适合想快速出课设成果、不愿意在排错上浪费两周的人。
2. Windows 游戏程序设计:搞懂 Win32 主循环比急着画鱼更重要
拿到一份 Windows 小游戏源码,先别急着看画鱼的部分。捕鱼达人这类程序,核心骨架是 Win32 窗口 + 消息循环。这一层如果不稳,后面的动画、碰撞、计分全是空中楼阁。这份源码在架构上走在一条“课程设计最稳”的路线上:调用系统原生 API,不引入额外依赖,编译环境一开就能跑。
2.1 为什么选 Win32 + GDI,而不是 DirectX
很多人会有个误区:游戏就该用 DirectX,GDI 太古老。但课程设计的时间是按周算的,不是按月。DirectX 的初始化、交换链、顶点缓冲区、Shader 管线,随便一个环节卡住,都可能消耗一整晚。而捕鱼达人的画面本质是一堆位图在屏幕上移动,GDI 的 BitBlt 和 StretchBlt 完全能应付。
这份源码选择 GDI 还有一个隐藏原因:它把重点放在“数据结构和逻辑”上,而不是图形 API 本身。鱼群怎么生成、怎么移动、碰撞怎么判定,这些才是老师真正想考察的点。如果你非要上 DirectX,最后很可能答辩时被问“为什么这里要 CreateRenderTargetView”,而你自己也解释不清。
我一般会建议:除非题目明确要求 3D 或复杂特效,否则课程设计阶段老老实实用 Win32 + GDI。运行时性能方面,普通 2D 游戏一帧只画几十个位图,GDI 的 CPU 消耗完全可控,唯一要解决的是闪烁问题,这个后面第 4 章会专门讲。
2.2 窗口注册与主循环:先让一个空窗口转起来
拿到源码后,我习惯先删掉所有游戏逻辑,只留一个空窗口,确认消息循环能跑通,再一点点加功能。这样做的好处是,一旦后面的鱼没显示,你能确定问题出在新增代码里,而不是最底层的窗口没建好。
#include <windows.h> LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wp, LPARAM lp); int WINAPI WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR lpCmdLine, int nCmdShow) { WNDCLASS wc = {0}; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.hCursor = LoadCursor(NULL, IDC_ARROW); wc.hbrBackground = (HBRUSH)GetStockObject(BLACK_BRUSH); wc.lpszClassName = TEXT("FishingGame"); if (!RegisterClass(&wc)) return -1; HWND hWnd = CreateWindow( TEXT("FishingGame"), TEXT("捕鱼达人"), WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 1024, 768, NULL, NULL, hInst, NULL); if (!hWnd) return -1; ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd); MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return 0; } LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wp, LPARAM lp) { switch (msg) { case WM_DESTROY: PostQuitMessage(0); break; default: return DefWindowProc(hWnd, msg, wp, lp); } return 0; }这段代码里有几个参数需要注意:wc.lpszClassName必须和CreateWindow的第一个参数一致,否则CreateWindow会直接返回NULL,很多人卡在这一步。hwnd的宽高 1024×768 只是初始窗口大小,实际客户区会更小,因为标题栏和边框占了一部分。后面的游戏坐标换算如果直接拿 1024 当宽度用,鱼会往右下方偏,这是第五部分的坑。
这个骨架用的是GetMessage,特点是窗口没有消息时线程会休眠,很适合普通界面程序,但做游戏不行。因为你希望即使没有任何鼠标键盘事件,窗口也在持续重绘,鱼也在不断移动。这时候就需要换成PeekMessage配合一个游戏循环。
2.3 把游戏逻辑塞进消息循环:PeekMessage 的取舍
标准游戏循环的写法是:每帧无论有没有消息都执行更新和渲染,但窗口消息也要及时处理,不能卡界面。PeekMessage是非阻塞的,有消息就取走,没有消息也立刻返回,这样就能做到“有空就画一帧”。
bool running = true; while (running) { MSG msg; while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { if (msg.message == WM_QUIT) { running = false; break; } TranslateMessage(&msg); DispatchMessage(&msg); } UpdateGame(); // 更新鱼群位置、碰撞、计分 RenderGame(); // 把一帧画面画到窗口 Sleep(10); // 避免 CPU 爆表 }这个双 while 结构是这个游戏的“心脏”。内层 while 把排队中的消息全部派发掉,防止鼠标点击积压;外层 while 负责游戏逻辑。Sleep(10)的主要目的不是限帧,而是让出 CPU 时间片。如果完全不休息,这个进程会吃掉一个核心的全部资源,笔记本风扇会直接起飞。10 毫秒意味着理论上限是 100 FPS,对捕鱼达人这种低强度游戏来说足够。
实际改编时,你可能会想用SetTimer来驱动游戏更新。这条路也能走,但要注意WM_TIMER消息和鼠标消息在同一个队列里,如果一帧更新耗时太长,消息会被延后处理,表现出来就是点击后鱼反应慢半拍。所以我更推荐上面的PeekMessage主动循环。
3. 鱼群、碰撞与捕捉判定:把捕鱼玩法拆成能算的模型
窗口骨架跑通之后,接下来是重头戏:让鱼动起来。捕鱼达人玩法的本质,是屏幕上若干条鱼以不同速度游动,玩家点击某个位置,如果点击点落在鱼的“判定区域”内,就视为捕到。这里面最关键的点是:用最简单的数据结构管理动态生成的鱼,同时保证碰撞判定不拖慢绘制。
3.1 鱼的数据结构:对象池比 new/delete 更稳
一开始我常犯的错误是:每生成一条鱼就new一个对象,鱼死掉就delete。程序规模小的时候看不出问题,但鱼数量一多,频繁的申请和释放会导致内存碎片,而且在游戏循环里漏掉delete还会内存泄漏。这份源码用了一个固定长度的对象池,思路更适合课程设计。
#define MAX_FISH 50 struct Fish { float x, y; // 鱼左上角坐标 float vx, vy; // 水平、垂直速度 int type; // 鱼类型,对应不同图片和分数 bool alive; // 当前帧是否存活 int frame; // 动画帧索引,用于实现鱼尾摆动 }; Fish g_fish[MAX_FISH]; int g_fishCount = 0; int g_clientWidth = 0; int g_clientHeight = 0;type不要简单当编号用,它应该同时决定鱼的图片资源、宽度、高度和分数。比如 type=0 是小丑鱼,宽 60,高 40,捕到得 10 分;type=1 是金枪鱼,宽 100,高 60,得 30 分。这些信息可以单独抽成一张配置表,但课程设计阶段写死在代码里反而更容易懂。
对象池的好处是:鱼的数量上限固定,不存在越攒越多导致内存膨胀的问题;查找空闲位置只需要遍历数组,找到alive == false的槽位即可。代价是代码里所有对鱼的访问都要先判断alive,否则你会处理到一堆残留数据。这个习惯越早建立越好。
3.2 鱼群生成与移动:边界反射的两种写法
生成鱼看起来简单,但细节不少。如果生成时让鱼的方向完全随机,很可能发生一堆鱼朝同一个角挤的情况。更自然的做法是:先从屏幕左或右两侧生成,鱼平着游过屏幕;也允许少量鱼从顶部或底部出现,但给它们的垂直速度不要太大。
void SpawnFish() { int idx = -1; for (int i = 0; i < MAX_FISH; i++) { if (!g_fish[i].alive) { idx = i; break; } } if (idx < 0) return; g_fish[idx].alive = true; g_fish[idx].type = rand() % 4; // 从左右两侧随机生成,避免全场鱼都往中间挤 if (rand() % 2 == 0) { g_fish[idx].x = -50; g_fish[idx].vx = 1 + (rand() % 3); } else { g_fish[idx].x = g_clientWidth + 50; g_fish[idx].vx = -(1 + (rand() % 3)); } g_fish[idx].y = rand() % g_clientHeight; g_fish[idx].vy = ((rand() % 3) - 1) * 0.5f; // 轻微上下漂移 }这里出现负数坐标是故意的。鱼从屏幕左侧外进入,所以初始 x 设为 -50,玩家看到的是鱼慢慢游进来,而不是“啪”地出现在边缘。type = rand() % 4让四种鱼随机出现,如果你希望大鱼的占比低一些,可以写一个加权随机,比如先把 0-99 的随机数分几个区间。
移动更新的时候,最忌讳直接在原坐标上加一个固定量。要做到“无论机器快慢,鱼的速度看起来一样”,应该乘上帧间隔时间。
void UpdateGame(float dt) { for (int i = 0; i < MAX_FISH; i++) { if (!g_fish[i].alive) continue; g_fish[i].x += g_fish[i].vx * 60.0f * dt; g_fish[i].y += g_fish[i].vy * 60.0f * dt; // 水平边界反向游 int fishW = GetFishWidth(g_fish[i].type); if (g_fish[i].x < -fishW || g_fish[i].x > g_clientWidth + fishW) { g_fish[i].vx = -g_fish[i].vx; } // 垂直边界反弹,避免鱼游出窗口 int fishH = GetFishHeight(g_fish[i].type); if (g_fish[i].y < 0) { g_fish[i].y = 0; g_fish[i].vy = -g_fish[i].vy; } if (g_fish[i].y > g_clientHeight - fishH) { g_fish[i].y = g_clientHeight - fishH; g_fish[i].vy = -g_fish[i].vy; } g_fish[i].frame++; } }注意速度乘了一个60.0f * dt,表示基础速度以“每秒 60 帧”为基准。假设vx是 2,那鱼每秒移动 120 像素;如果机器的帧率只有 30,dt会变大,鱼移动的总距离仍然一致。这是从“每帧移动固定像素”过渡到“每秒移动固定像素”的关键,也是后面优化章节要再提的点。
3.3 碰撞检测:中心距离比矩形包含更宽容
捕鱼的碰撞判定可以有两种做法。第一种是用矩形相交,即检测鼠标点击点是否落在鱼的RECT里。实现简单,但鱼的位图往往不是严格矩形,鱼头和鱼尾有大量透明像素,玩家会觉得“明明点空了却抓到鱼”。第二种是算点击点与鱼图片中心的距离,小于某个半径就判定命中。这个方案手感更好,也是这份源码采用的方式。
bool HitTest(POINT pt, const Fish& fish) { float centerX = fish.x + GetFishWidth(fish.type) / 2.0f; float centerY = fish.y + GetFishHeight(fish.type) / 2.0f; float dx = pt.x - centerX; float dy = pt.y - centerY; float dist = sqrt(dx * dx + dy * dy); return dist < GetFishRadius(fish.type); }GetFishRadius通常小于鱼图片宽度的一半,留出一些容错空间。如果设成宽度的 60%,玩家会明显感觉到“这条鱼很好抓”;如果设成 30%,又会觉得“明明点中了鱼头,却提示没捕到”。这个参数没有标准答案,建议直接在WM_LBUTTONDOWN处理函数里加一个临时调试开关,命中时把鱼的alive设为 false,没命中时弹一下标题栏文字,灵敏度调到你满意为止。
命中之后的处理也很关键:把鱼标记为死亡、累加分数、并追加一个“网罩动画”对象。网罩可以理解为另一组独立对象,有自己的坐标、放大比例和存活时间,画在鱼的上层,这样玩家就能看到“网住鱼”的视觉反馈。
4. 画面渲染与鼠标交互:双缓冲和命中判定决定“手感”
鱼的数据和逻辑完成后,下一步是把画面画出来。很多人的课设死在这一步,因为屏幕总在闪,或者鼠标点击和画面上的鱼位置对不上。这两个问题分别对应双缓冲绘图和客户区坐标换算,是这份资源里最容易翻车也最值得学的地方。
4.1 双缓冲绘图:三行代码根治闪烁
先解释闪烁的根源:每次在窗口上直接画图,等于先擦掉旧图,再画新图。Windows 的擦除和绘制是分开的,中间会有极短的时间窗口暴露背景色,快速交替时人眼就看到了“闪”。双缓冲的思路是先把整帧画到内存中的 DC 上,然后一次性把内存 DC 的内容整体打印到窗口,这个过程用户看不到中间状态。
HDC g_memDC; HBITMAP g_memBmp; void InitBackBuffer(HWND hWnd) { HDC hdc = GetDC(hWnd); g_memDC = CreateCompatibleDC(hdc); RECT rc; GetClientRect(hWnd, &rc); g_memBmp = CreateCompatibleBitmap(hdc, rc.right, rc.bottom); SelectObject(g_memDC, g_memBmp); ReleaseDC(hWnd, hdc); } void RenderGame(HWND hWnd, HDC hdc) { // 1. 用背景色填充内存 DC FillRect(g_memDC, &rcClient, (HBRUSH)GetStockObject(BLACK_BRUSH)); // 2. 把所有鱼画到内存 DC for (int i = 0; i < MAX_FISH; i++) { if (!g_fish[i].alive) continue; DrawFish(g_memDC, g_fish[i]); } // 3. 一次 BitBlt 把完整的一帧送到窗口 BitBlt(hdc, 0, 0, rcClient.right, rcClient.bottom, g_memDC, 0, 0, SRCCOPY); }这里必须强调一个常见错误:内存 DC 的位图尺寸要和窗口客户区同步。窗口在WM_SIZE消息里变化时,你就得重新CreateCompatibleBitmap,否则画面会被截断,或者变成一片黑。也别忘了在WM_DESTROY里用DeleteObject和DeleteDC释放,不然排查内存泄漏时你会一头雾水。
4.2 鼠标瞄准与网罩动画:坐标换算不能想当然
鼠标的WM_LBUTTONDOWN消息里,lParam的低 16 位是鼠标在客户区内的 x 坐标,高 16 位是 y 坐标。注意是“客户区”而不是“窗口”,窗口的标题栏和边框不属于客户区。如果你用整个窗口的左上角作为原点,鼠标点击位置会整体偏右下,导致你点不到鱼。
case WM_LBUTTONDOWN: { int mouseX = GET_X_LPARAM(lParam); int mouseY = GET_Y_LPARAM(lParam); ShootNet(mouseX, mouseY); return 0; }GET_X_LPARAM是windowsx.h提供的宏,比直接LOWORD(lParam)更可靠。因为LOWORD只取低 16 位,当鼠标坐标是负值时会被截断,高 DPI 缩放下也会踩坑。
网罩动画可以用一个简单的结构体来管:
struct Net { float x, y; float scale; float alpha; bool active; };创建网罩时,scale从 0.2 开始,每帧递增 0.05,alpha从 1.0 递减。绘制时用StretchBlt把网罩位图拉伸到当前尺寸,同时用AlphaBlend实现半透明。如果嫌AlphaBlend参数多,课程设计阶段用一个不透明、逐渐变大的网罩也说得过去。视觉效果弱一点,但重点是逻辑完整。
4.3 计分与状态切换:把课设从“能玩”变“能演示”
一个完整的捕鱼课设不可能只有鱼在游。计分、游戏结束、重新开始,这三样必须做成清晰的状态机。最简单的状态机只包含PLAYING、PAUSED、GAMEOVER三个状态,用一个枚举变量g_gameState控制。每次UpdateGame开头检查状态,不是PLAYING就直接跳过移动和碰撞;渲染时则画出不同的覆盖层。
enum GameState { STATE_PLAYING, STATE_PAUSED, STATE_OVER }; GameState g_state = STATE_PLAYING; void TogglePause() { if (g_state == STATE_PLAYING) g_state = STATE_PAUSED; else if (g_state == STATE_PAUSED) g_state = STATE_PLAYING; }计分部分不要直接改一个全局g_score,最好抽一个函数AddScore(int delta),里面同时处理“最高分记录”的更新。这个函数写得好,答辩时老师问“你的分数怎么管理”,你就能顺理成章地说出封装思想。
5. 避坑指南:课程设计里最常见的五个翻车点
这五个问题都是我实际拆源码和调试时反复遇到的。每一条都按现象、原因、解决的顺序写,你可以直接对照排查。提前看完这一章,能省下至少两个通宵。
5.1 画面疯狂闪烁:双缓冲没生效
现象:鱼一移动,整个窗口闪得像放老电影,甚至能看到背景色闪烁。
原因:最常见的情况是创建了内存 DC,但实际绘图时直接画到了hdc上,内存 DC 变成了摆设。另一个原因是内存位图尺寸没有跟随客户区大小,画面只绘制了一部分,剩余区域反复擦除重画。
解决:检查RenderGame里所有绘制函数,确认鱼和背景都画在g_memDC上,最后只保留一次BitBlt到窗口。同时在WM_SIZE处理中重新创建内存位图,保证尺寸同步。方法是在每个绘制函数里加一个OutputDebugString,打印当前绘制目标 DC 的句柄,看是不是同一个。
5.2 图片加载失败或显示黑块:资源路径错误
现象:程序编译通过,运行后窗口是黑底,鱼不显示;或者图片显示成一个黑色方块。
原因:用了相对路径,比如LoadImage(NULL, "fish.bmp")。但fish.bmp存放在源码目录,而程序的工作目录通常是Debug/或Release/,运行时根本找不到这个文件。更大的坑是位图不是 24 位或 32 位颜色,GDI 在某些颜色深度下会绘制成黑块。
解决:加载图片时用GetModuleFileName拼出当前 exe 所在目录的绝对路径,再拼接图片文件名。颜色深度问题可以用画图工具把位图另存为 24 位 BMP,或者直接用LoadImage的LR_LOADFROMFILE参数配合LR_CREATEDIBSECTION。养成习惯:先GetFileAttributes确认文件存在,再执行LoadImage。
5.3 中文乱码:字符集设置不一致
现象:窗口标题栏出现“閿欒 鐨?”,MessageBox里的中文也变成乱码。
原因:项目字符集设置成“多字节字符集”,但代码里字符串使用了L"捕鱼达人"这样的宽字符字面量;或者反过来,设置成 Unicode 却用了普通字符串。只要是两种混用,必然乱码。
解决:在开发工具的项目设置里统一切到“使用 Unicode 字符集”,所有字符串统一用TEXT()或_T()宏包裹。不要在某个文件里强行改#define UNICODE,那样只会让一部分文件是宽字符、一部分还是窄字符,问题更隐蔽。检查的办法是:如果代码里同时出现char*和wchar_t*的字符串常量,优先级最高就是要统一的地方。
5.4 鼠标点到鱼身上却没反应:坐标原点用错了
现象:鼠标在画面下方点击时,判定位置明显偏右下方,鱼游到屏幕底部时尤其难抓。
原因:直接用LOWORD(lParam)当作坐标,但这个宏在WM_LBUTTONDOWN里得到的是客户区坐标。如果某个地方误用了GetWindowRect得到窗口坐标,再做判断就会整体偏移。另外,如果窗口设置了高 DPI 缩放,系统可能会把鼠标坐标按真实像素上报,而绘图使用的是缩放后的像素,两边不一致。
解决:所有鼠标交互都用GET_X_LPARAM/GET_Y_LPARAM,并且统一存储在自定义结构体里。涉及高 DPI 时,在程序入口调用SetProcessDPIAware(),让窗口不参与系统缩放,保持逻辑像素和物理像素一致。这是最省事的方案,也是这个源码里采用的方式。
5.5 关掉窗口后内存报错:资源释放顺序混乱
现象:点击关闭按钮后,弹出一个“访问冲突”或者“Debug Assertion Failed”对话框,程序崩溃退出。
原因:常见于WndProc里收到WM_DESTROY时,某些全局对象已经失效,但游戏循环还在访问;或者内存 DC、位图、图片句柄在窗口销毁前没有释放,析构顺序和创建顺序不一致。
解决:在WM_DESTROY里先停止游戏循环,再释放所有 GDI 资源,最后PostQuitMessage(0)。释放顺序严格按“先创建的后释放”:先删图片和字体,再删位图,再删内存 DC。如果你在全局WinMain退出后又做了清理,要防止重复释放同一个句柄。一个简单的保护办法是用if (g_memBmp) { DeleteObject(g_memBmp); g_memBmp = NULL; },这样无论如何都不会删两次。
6. 从“能交差”到“能答辩”:三个优化让代码经得起追问
课程设计交一份能跑的程序不难,难的是答辩时面对“你这里为什么要这么写”的追问。我建议你在提交前做三件事。
第一件是给游戏加一个“固定时间步长”的帧率控制。前面第 3 章我们用了dt做每帧更新,但如果一帧耗时波动很大,鱼的速度仍然会忽快忽慢。更稳的做法是设定一个固定步长,比如每帧代表 16.7 毫秒,逻辑更新时按步长累积,渲染在整帧完成后进行。代码量增加不多,但答辩时你可以明确说出“我用了累计时间还是固定步长”,这是很加分的设计。
第二件是把游戏状态和业务逻辑分开。你不应该依赖某个窗体函数来存分数,也不应该在绘制函数里判断“游戏是否结束”。抽出一个GameManager层面的结构,管理计分、状态、最高分,窗口只负责把消息转进去和把画面画出来。老师一问你“如果以后要加网上下发鱼群配置怎么改”,你就能顺着这个分层答下去。
第三件是写一个调试显示开关。按住某个键时在窗口标题栏或左上角显示“当前鱼数量、当前帧率、上一次命中坐标”。这个开关看着不起眼,却是你排查“鱼为什么会卡住”最有力的工具。我自己的习惯是设置一个F12切换调试面板,答辩时也会主动展示这个功能,让老师看到你“不是在赌它能跑,而是有意识地在做可观测性”。
从那以后,我每次做完一个 Windows 课设,都强制自己走一遍固定步长、状态分离、调试开关这三件事。有些时候只需要半小时,却能让评审看到的代码从“能跑”变成“有设计”。希望帮到你。
本文还有配套的精品资源,点击获取