简介:这是一份基于Windows平台的C++课程设计项目,主题为捕鱼达人小游戏,适合正在学习Windows编程或需要完成类似课程设计的高校学生参考。项目展示了如何利用Windows API创建窗口、处理消息循环与鼠标输入,并结合GDI绘图绘制背景、鱼群、炮台等元素;游戏逻辑涵盖鱼的随机游动、子弹发射、碰撞检测与计分机制,代码以面向对象方式组织,有助于理解封装、继承和多态的实际应用。资源包共36个文件,压缩后约7.42MB,其中包含4个h头文件与1个cpp主程序,便于查看整体结构;10个bmp位图用于游戏界面与鱼图素材,6个ogg、2个mp3及1个wav音频用于背景音乐和音效,另有lib库、项目工程文件(dsp、dsw)和说明文本,方便在Visual Studio中直接打开编译。该资源已有1307人学习下载,作为课程设计范例,能够提供从界面搭建到资源管理的完整思路,值得对Windows游戏开发感兴趣的同学研读源码并二次修改。
1. 先别急着写游戏逻辑:这个捕鱼达人 Demo 能给你的东西
课程设计交稿前一周,我还在控制台里画贪吃蛇,直到把捕鱼达人这块硬骨头啃下来,才算真正把 Windows 程序设计的几大件串了起来。这个用 C++ 写的 Windows 小游戏,不依赖任何第三方库或游戏引擎,只靠 Win32 API 和 GDI 绘图就把窗口创建、消息循环、定时器、鼠标交互、碰撞检测全部过了一遍。它既是课程设计报告里拿得出手的成果,也是想搞清楚 Windows 游戏背后运行逻辑的最佳练手对象。我拆过这个项目后最大的感受是:捕鱼达人远比看起来简单,它的核心其实是一套按帧更新的状态机,窗口、鱼群、炮台、渔网都在为这个状态机服务。
2. 程序骨架与运行方式:Win32 消息循环 + GDI 双缓冲
2.1 WinMain 到消息循环:一个 Windows 程序的生命线
很多人写 Windows 游戏习惯先画鱼再想窗口,结果鱼画好了也不知道往哪放。正确的顺序是先把窗口程序和消息循环搭起来,再往里面填游戏内容。这个捕鱼达人项目的入口不是main,而是WinMain,这是 Windows GUI 程序的标准入口:
#include <windows.h> LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam); int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { const wchar_t CLASS_NAME[] = L"FishingGameWindow"; WNDCLASSEX wc = {}; wc.cbSize = sizeof(WNDCLASSEX); wc.lpfnWndProc = WndProc; // 窗口过程函数 wc.hInstance = hInstance; wc.hCursor = LoadCursor(NULL, IDC_ARROW); wc.hbrBackground = (HBRUSH)GetStockObject(BLACK_BRUSH); wc.lpszClassName = CLASS_NAME; RegisterClassEx(&wc); HWND hWnd = CreateWindowEx( 0, CLASS_NAME, L"捕鱼达人", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 1024, 768, NULL, NULL, hInstance, NULL); if (!hWnd) return 0; ShowWindow(hWnd, nCmdShow); MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return (int)msg.wParam; }这段代码有四个关键参数要理解:hInstance是当前程序的实例句柄,注册窗口类和创建窗口都要用到;lpCmdLine是命令行参数,在这个游戏里不会用到;nCmdShow控制窗口初始显示方式,一般透传给ShowWindow就行。WNDCLASSEX里最容易被忽略的是hbrBackground,我直接把背景设成黑色,这样每帧清屏后底色就是黑的,不会出现灰蒙蒙的界面。
GetMessage在收到WM_QUIT时返回 0 从而退出循环,这是 Windows 消息泵的标准写法。注意RegisterClassEx的参数和CreateWindowEx的类名要保持一致,而且这里用了宽字符串前缀L,因为窗口类名的内部编码是 UTF-16。很多人在这一步踩了第一个坑:多字节字符集项目里直接写"FishingGameWindow",注册出来的类和创建窗口时传的类名编码不一致,窗口直接创建失败,返回值是 NULL。
2.2 窗口过程:把输入和定时器调度到对应逻辑
窗口创建之后所有输入、绘制、定时器事件都会涌进WndProc这个回调函数。游戏逻辑的分发都靠它撑着,我把这个项目里的消息处理拆成五个分支:
LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: // 游戏初始化:创建鱼群、炮台、计分面板的初始状态 InitGame(); // 每 30ms 触发一次 WM_TIMER,大约 33 帧/秒 SetTimer(hWnd, TIMER_GAME, 30, NULL); return 0; case WM_TIMER: // 只有游戏进行中才更新逻辑,暂停时直接忽略 if (!g_paused) UpdateGame(); InvalidateRect(hWnd, NULL, FALSE); // 触发重绘 return 0; case WM_MOUSEMOVE: UpdateCannonAngle(lParam); // 炮台跟随鼠标转向 return 0; case WM_LBUTTONDOWN: FireNet(); // 左键发射渔网 return 0; case WM_PAINT: DrawGame(hWnd); // 内部做双缓冲再绘制 return 0; case WM_DESTROY: KillTimer(hWnd, TIMER_GAME); // 必须成对出现 PostQuitMessage(0); return 0; } return DefWindowProc(hWnd, msg, wParam, lParam); }这里必须强调一件事:WM_TIMER不是实时中断,它只是往消息队列里投一条消息,优先级低于键盘和鼠标。所以游戏逻辑不能做得太重,一帧内要完成所有鱼的位置更新、网的状态推进、碰撞检测,否则帧率会不稳定。常见做法是把UpdateGame内部的耗时代码压到最低,绘制全部放WM_PAINT,两者分开。
另一个容易翻车的细节是SetTimer的第三个参数。30ms 是课程设计里比较稳的刷新间隔,调到 10ms 看似更流畅,实际上 Windows 定时器消息的投递逻辑根本撑不住这个频率,效果反而是时快时慢。如果真的需要高帧率,应该用独立渲染线程,但那是后话。
2.3 双缓冲绘图:为什么我的画面一直闪
捕鱼游戏的画面里要同时画背景、数十条鱼、炮台、渔网和计分文字,如果直接在hdc上逐个绘制,屏幕上会出现明显的闪烁,因为每个笔画的擦除和重绘是交错的。处理这个问题的标准做法是双缓冲:先在内存画布上画完整帧,再一次 BitBlt 拷到窗口上。
void DrawGame(HWND hWnd) { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); RECT rc; GetClientRect(hWnd, &rc); int w = rc.right - rc.left; int h = rc.bottom - rc.top; // 创建内存兼容 DC 和位图 HDC memDC = CreateCompatibleDC(hdc); HBITMAP bmp = CreateCompatibleBitmap(hdc, w, h); HBITMAP oldBmp = (HBITMAP)SelectObject(memDC, bmp); // 所有游戏元素画到 memDC 上 DrawBackground(memDC, w, h); DrawFish(memDC); DrawNet(memDC); DrawCannon(memDC); DrawScore(memDC); // 一次性拷贝到窗口 BitBlt(hdc, 0, 0, w, h, memDC, 0, 0, SRCCOPY); // 反向回滚并释放 GDI 对象 SelectObject(memDC, oldBmp); DeleteObject(bmp); DeleteDC(memDC); EndPaint(hWnd, &ps); }CreateCompatibleDC创建的内存画布初始是 1x1 像素,必须用CreateCompatibleBitmap给它分配一块足够大的位图,否则画出来什么都看不见。SelectObject把位图选进 memDC,画完之后一定记得把旧位图选回去,再删除新建对象。这四行收尾代码是防止 GDI 泄漏的关键,漏掉DeleteObject(bmp)会让程序每帧泄漏一个位图对象,跑几分钟后画面开始异常甚至卡死。
双缓冲对捕鱼游戏还有另一个好处:我可以先在内存里把鱼画好,再根据当前时间轻微偏移,实现无闪烁的平滑移动。如果不做双缓冲,鱼一多画面会闪得像放老电影,答辩时演示效果大打折扣。
2.4 编译与运行:从 IDE 到命令行
这个项目不依赖第三方库,编译起来非常省事。我常用两条路任选一条:用 C++ 集成开发环境新建一个“Windows 桌应用程序”空项目,把源码文件加进去直接编译;或者装好 MinGW-w64,在命令行里执行:
g++ main.cpp -mwindows -o fishing.exe -lgdi32 -lwinmm这里-mwindows告诉链接器这个程序是 GUI 子系统,运行时不弹出控制台窗口;-lgdi32链接 GDI 绘图函数;-lwinmm链接多媒体库,我后面加音效时要用到它。如果不用音效,-lwinmm可以去掉。
用 IDE 做这个项目时会遇到一个隐蔽的配置问题:IDE 默认项目字符集可能是多字节,而窗口类名用的是宽字符串,编译能过但运行时报错。我一般会把项目字符集显式设为 Unicode,或者在代码里全部用TEXT()宏包裹字符串文本,两种方式选一种然后统一到底。后面第四章的乱码问题也是从这来的。
3. 核心玩法拆解:一条鱼从生成到进账的完整链路
3.1 鱼的数据结构:对象池代替边写边 new
捕鱼游戏要维护几十条同时在屏的鱼,如果每生成一条就new一个对象,死亡时再delete,游戏每帧都要做堆内存分配,时间抖动直接反映在画面卡顿上。这个项目用的是对象池,预先开一块固定数组,鱼死亡只把标记位改掉,下次生成直接复用下标。
#define MAX_FISH 30 struct Fish { bool alive; // false 表示可复用 int type; // 0 小丑鱼, 1 河豚, 2 鲷鱼, 3 金鲨 float x, y; // 鱼的中心坐标 float speed; // 每秒移动像素数 int dir; // 1 向右游, -1 向左游 int animFrame; // 用于表现鱼尾摆动 }; Fish fishPool[MAX_FISH];初始化时把alive全部置 false,生成鱼就是遍历找到第一个alive == false的槽位,填入坐标、类型、速度和方向。回收鱼同理,把alive改成 false 即可。整个生命周期内没有任何堆内存操作,帧率曲线非常平缓。
type字段决定的不只是外观,还绑定了后续的概率结算和分值,我写了个简单的映射表,在SpawnFish里按类型设置初值:小丑鱼速度 120 像素/秒,金鲨速度 80 像素/秒但体型大、价值高。鱼的形象越值钱游得越慢,这也符合捕鱼达人“高风险高回报”的直觉。
void SpawnFish() { for (int i = 0; i < MAX_FISH; ++i) { if (fishPool[i].alive) continue; fishPool[i].alive = true; fishPool[i].type = rand() % 4; fishPool[i].dir = (rand() % 2 == 0) ? 1 : -1; fishPool[i].x = (fishPool[i].dir == 1) ? -80.0f : g_clientW + 80.0f; fishPool[i].y = (float)(rand() % (g_clientH - 120)) + 60.0f; fishPool[i].speed = 80.0f + (float)(rand() % 80); fishPool[i].animFrame = rand() % 4; return; } }生成位置特意放在屏幕外 80 像素的左右两侧,这样鱼是“游进”视野的而不是凭空出现在窗口里。y限制在 60 到g_clientH - 60之间,避免鱼游进顶部计分栏和底部炮台区域。还有一个细节:生成时把animFrame随机化,这样同一种鱼刚出现的动画帧不同,视觉上不会整齐划一像阅兵方阵。
3.2 定时器驱动:鱼的游动、转向与消失
鱼的移动逻辑放在UpdateGame里,由WM_TIMER每 30ms 调一次。帧间隔不是固定的 30ms,定时器在某些情况下会延迟甚至丢消息,所以要用真实时间差做增量更新,不然鱼的速度会被系统负载影响。
void UpdateGame() { DWORD now = GetTickCount(); float dt = (now - g_lastTime) / 1000.0f; g_lastTime = now; // dt 上限 50ms,防止窗口拖动时瞬间跨越大距离 if (dt > 0.05f) dt = 0.05f; for (int i = 0; i < MAX_FISH; ++i) { if (!fishPool[i].alive) continue; fishPool[i].x += fishPool[i].speed * fishPool[i].dir * dt; fishPool[i].animFrame++; if (fishPool[i].animFrame >= 4) fishPool[i].animFrame = 0; // 游出屏幕外就回收 if (fishPool[i].x < -120 || fishPool[i].x > g_clientW + 120) fishPool[i].alive = false; } UpdateNets(dt); SpawnTimer += dt; if (SpawnTimer >= 0.4f) { SpawnTimer = 0.0f; SpawnFish(); } }GetTickCount返回的是系统启动以来的毫秒数,用当前值减上次值得到真实间隔。dt 限定 50ms 是防止窗口被拖动时WM_TIMER消息积压后一次性处理导致鱼群瞬移。鱼的回收线是屏幕外 120 像素,比生成时的 80 像素多,这样鱼不会刚露头就消失。
每 0.4 秒生成一条鱼的节奏在满屏 30 条鱼时大约每秒增加 2.5 条,而鱼游出屏幕也需要几秒,所以屏内常驻鱼数量会自动趋于稳定。如果想让游戏更刺激,可以把SpawnTimer间隔改到 0.2 秒,鱼群密度会明显翻倍,这个参数是调节游戏难度的第一抓手。
3.3 炮台与渔网:从鼠标角度到子弹网络
炮台位于窗口底部中央,鼠标移动决定炮管朝向,左键发射渔网。角度计算是整个模块的基础,用了atan2f而不是atan,因为atan无法区分象限,鼠标移到炮台左边时会把朝向计算到右边去。
void UpdateCannonAngle(LPARAM lParam) { int mx = GET_X_LPARAM(lParam); int my = GET_Y_LPARAM(lParam); float dx = mx - g_cannonX; float dy = my - g_cannonY; g_angle = atan2f(dy, dx); }GET_X_LPARAM和GET_Y_LPARAM是 Windows 提供的宏,用来从lParam的低 16 位和高 16 位取出鼠标坐标,比自己移位取模安全,至少不会把负坐标弄成无符号数。g_angle是弧度制,范围从 -π 到 π,后续画炮管时cos(g_angle) * 长度就能得到炮口世界坐标。
发射渔网使用的是网池,结构和鱼池类似。每张网记录位置、方向、当前半径和存活状态:
#define MAX_NETS 8 struct Net { bool active; float x, y; float dirX, dirY; // 单位方向向量乘以速度 float radius; // 网当前半径,飞行中持续变大 }; void FireNet() { for (int i = 0; i < MAX_NETS; ++i) { if (g_nets[i].active) continue; g_nets[i].active = true; g_nets[i].x = g_cannonX + cosf(g_angle) * 60.0f; g_nets[i].y = g_cannonY + sinf(g_angle) * 60.0f; g_nets[i].dirX = cosf(g_angle) * 600.0f; g_nets[i].dirY = sinf(g_angle) * 600.0f; g_nets[i].radius = 8.0f; return; } }网池数量限制为 8,意味着玩家最多同时有 8 张网在屏上,第 9 次点击会被吞掉。这实际上是个隐性冷却机制,防止玩家狂点鼠标造成满屏都是网。炮口 60 像素的偏移让网从炮管口发出,而不是从炮台中心冒出,视觉上顺眼很多。
网速 600 像素/秒,在 1024 宽的窗口里从炮台飞到屏幕边缘大约 1.5 秒,这段时间内网的半径从 8 增长到上限 100,正好给碰撞检测留出窗口。这里的方向向量直接乘了速度,省了每次更新都重新归一化的步骤。
3.4 命中判定与计分:概率系统才是捕鱼的精髓
渔网的碰撞检测是游戏手感的关键。网在飞行中不断展开变大,每帧遍历所有存活的鱼,计算网心与鱼心的距离,小于判定半径就算命中。这里用的判定半径是网当前半径加鱼自身宽度的一半,所以大鱼更容易被网刮到,但能不能捕到还得看概率。
bool CheckNetHitFish(Net* net, Fish* fish) { float dx = fish->x - net->x; float dy = fish->y - net->y; float dist = sqrtf(dx * dx + dy * dy); float hitRadius = net->radius + 20.0f; if (dist > hitRadius) return false; // 大鱼有更大的体型补偿,更容易碰到网,但捕获概率更低 int captureRate = GetCaptureRate(fish->type); int roll = rand() % 100; if (roll < captureRate) { g_score += GetScoreValue(fish->type); fish->alive = false; return true; } return false; }GetCaptureRate和GetScoreValue是两张查表函数,我把概率和分值做成了常量参数,方便答辩时现场调数值。各档位的典型配置是这样的:
| 鱼种 | 基础捕获概率 | 分值 |
|---|---|---|
| 小丑鱼 | 90% | 10 |
| 河豚 | 75% | 20 |
| 鲷鱼 | 50% | 50 |
| 金鲨 | 25% | 200 |
这个概率设计是“鱼越大越难抓但越值钱”的模型。这里有个容易被忽略的细节:rand() % 100的分布本身就是不均匀的,而且低位的随机性差。对课程设计来说够用,但如果想要更稳的均匀性,我会改成(int)(rand() * 100.0 / RAND_MAX),用浮点归一化消除低位偏差。
概率判定做完后如果成功,直接给g_score累加,不动网的状态,让网继续飞行还能捕第二条鱼。这样一张网贴着鱼群穿过时可能一串捕到两三条,手感上特别解压,也是捕鱼达人的爽感来源。网飞出屏幕后才回收,和鱼的回收方式一致。
4. 避坑手册:GDI 泄漏、中文乱码与消息阻塞
4.1 绘制与资源类的坑:闪烁、GDI 泄漏、中文乱码
现象:游戏画面一闪一闪,鱼多的时候像在放老电影。 原因:没有用双缓冲,直接在WM_PAINT的hdc上一个元素一个元素地画,DefWindowProc默认还会用背景色擦除窗口客户区,擦除和绘制交替就形成了闪烁。 解决:把所有绘制集中到内存 DC,最后BitBlt一次拷贝。另外在窗口过程里拦截WM_ERASEBKGND直接返回 1,不让系统擦背景。我做完这两步,闪烁问题彻底消失。
现象:程序跑几分钟越来越卡,任务管理器里 GDI 对象数量一只往上涨。 原因:绘制函数里每次CreateSolidBrush、CreatePen后没用DeleteObject释放,GDI 对象是系统资源,进程不退出就不回收。帧循环每帧泄漏几个,数量累积到一万左右窗口就开始白屏。 解决:所有画笔画刷用完立即SelectObject换回旧对象再DeleteObject。如果嫌每个绘制函数都写释放代码啰嗦,可以把固定颜色的画笔画刷放在初始化时创建,退出时统一销毁,帧内只选择不创建。
现象:窗口标题和计分文字里的中文显示成问号或者乱码。 原因:项目字符集是多字节,但代码里用了L"..."宽字符串,或者反过来:Unicode 项目里直接用窄字符串字面量。窗口类名是宽字符,文本绘制函数又混用了TextOutA和TextOutW,编码全乱。 解决:项目属性里把字符集设为“使用 Unicode 字符集”,代码中所有字符串统一用TEXT()宏或L前缀,不要混用。MessageBox弹窗测试时最容易暴露这个问题,因为弹窗内容也是走同一套编码。
4.2 消息与定时器类的坑:点不动、定时器叠加、鱼瞬移
现象:鼠标点击左键毫无反应,渔网不发射,按键也像被吞了一样。 原因:WndProc的default分支没有写return DefWindowProc(...),或者某个case里直接return 0把WM_LBUTTONDOWN之前的关键消息拦截了。更隐蔽的是lpfnWndProc在注册窗口类时写成了别的函数名,实际挂在窗口上的过程跟当前代码不是同一个。 解决:DefWindowProc兜底返回所有未处理消息;确认注册和创建窗口用的是同一个回调函数名;在WM_LBUTTONDOWN分支加一个OutputDebugString输出日志验证消息确实到达。
现象:游戏越跑越快,鱼的速度肉眼可见地翻倍。 原因:SetTimer在多个地方被调用,比如WM_CREATE里设了一个,初始化函数里又设了一个,同一个定时器 ID 被重复创建。Windows 并不是后建覆盖前建,而是多个定时器同时生效,WM_TIMER消息密度翻倍。 解决:全程只在一个位置创建定时器,建议WM_CREATE;配套地在WM_DESTROY里KillTimer。开发期可以在InitGame开头加一行KillTimer(hWnd, TIMER_GAME)再SetTimer,双保险。
现象:窗口拖动时鱼瞬移一大段距离,松开鼠标后鱼的位置跳变。 原因:UpdateGame里用固定增量更新坐标,假设每帧间隔恒为 30ms。窗口拖动期间WM_TIMER被阻塞,积压恢复后连续触发几次回调,鱼坐标按固定量累加,帧间隔远大于 30ms,位移就爆炸了。 解决:用GetTickCount()计算真实时间差 dt,再把 dt 限制在 50ms 以内。这不仅是避坑,也是给后续加子弹速度、动画时长做的基础设施,所有和“每秒移动多少像素”相关的参数都靠 dt 换算。
5. 二次开发:把课程设计改出自己的味道
这个项目已经能玩,但直接交上去和别人的课程设计没什么区别。我建议按下面的优先级做扩展,工作量小、答辩亮点足。
第一个改造是给鱼换贴图。现在的鱼是 GDI 画的椭圆加尾巴,换成外部图片后画面质感和工程量都会上一个台阶。思路是在InitGame里用LoadImage加载几张 BMP 位图,绘制鱼时按鱼的朝向用BitBlt或TransparentBlt贴图,注意LoadImage返回的句柄在WM_DESTROY里要DeleteObject,不然后台资源同样泄漏。
第二个改造是加炮台等级。这个改动小但效果明显:在Fish结构外再维护一个g_cannonLevel,每抓到一条鱼得的分按倍率计算,花积分升级炮台,升级后GetCaptureRate整体上调 10%。这个机制不改动任何游戏循环结构,只改两个查表函数的返回值,一行测试逻辑都不用挪。
第三个改造是存档系统。捕鱼游戏的高分列表最适合展示文件读写能力:
void SaveScore(int score) { FILE* fp = fopen("score.dat", "wb"); if (fp) { fwrite(&score, sizeof(score), 1, fp); fclose(fp); } }读取时用fopen("score.dat", "rb")配合fread,文件不存在就默认 0 分。把SaveScore挂到WM_DESTROY里,玩家每次关窗口都会落盘,答辩时能证明“程序退出后数据还在”。如果嫌二进制文件不直观,也可以改成写文本文件,无非是fprintf和fscanf的差别。
改造完一定要做三个验证:第一,开着任务管理器盯 GDI 对象,跑十局看数量是否稳定;第二,连续点击左键到网池满,确认第 9 次点击被吞掉但不崩溃;第三,拖动窗口再松开,确认鱼没有瞬移。这三个验证能兜住大部分隐藏问题。
我从那以后每次动定时器相关代码,都会先在纸上画出SetTimer和KillTimer的配对关系,再回去检查窗口过程的WM_DESTROY分支,这门课教给我的不只是 API,还有对系统资源保持敬畏的习惯。希望这个捕鱼达人项目能帮你少走几步弯路,让你的课程设计既有得玩,也有得讲。
本文还有配套的精品资源,点击获取