简介这是一份基于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[] LFishingGameWindow; 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还有对系统资源保持敬畏的习惯。希望这个捕鱼达人项目能帮你少走几步弯路让你的课程设计既有得玩也有得讲。本文还有配套的精品资源点击获取