C++ Win32 捕鱼达人课程设计:消息循环、GDI双缓冲与碰撞检测全解析
简介这是一份基于 Windows 平台、使用 C 完成的“捕鱼达人”小游戏课程设计资源面向正在学习 Windows 程序设计与面向对象开发的在校学生。项目以捕鱼游戏为场景完整覆盖窗口创建、GDI 图形绘制、鼠标键盘事件响应、多线程与定时器、碰撞检测、得分计算等关键环节代码按模块划分灵活运用继承、多态与封装便于逐段阅读和二次修改。压缩包共 36 个文件大小约 7.42MB内部包含 bmp 图像素材、ogg/mp3/wav 音效、cpp/h 源码、txt 说明文档以及 dsp/dsw 工程文件素材、代码与配置文件齐全可直接查看项目结构和运行逻辑。已有 1307 人学习下载。通过阅读源码可掌握 Windows API 消息循环、图像资源加载与释放、防止内存泄漏、游戏循环与碰撞算法等实用技术也能借鉴其完整工程组织方式为同类课程设计提供可落地的参考方案。1. 捕鱼达人课程设计一份 C Win32 工程能带给你什么如果你正在找 Windows 程序设计这门课的课程设计源码捕鱼达人小游戏是出现频率最高的选题之一也是能把 Win32 窗口、GDI 绘图、消息循环、碰撞检测全部串起来的最好载体。这份资源不是那种网上随手复制、十几行代码拼出来的 demo而是一个结构完整、能直接编译运行的 C 工程从窗口创建到鱼群生成从开炮到计分结算逻辑全部在 Win32 API 层面手写完成没有用到任何游戏引擎。适合两类人一是课程设计选题定了但不知道从哪下手的同学二是已经写过简单窗口程序、想看看一个完整小游戏怎么组织代码的初学者。照着它跑通一遍比看十遍教材上零散的 API 讲解都管用。2. 先拆双循环骨架消息循环与游戏循环的分工2.1 为什么这个项目需要“两套循环”同时转Windows 程序天生是事件驱动鼠标点击、键盘按下、窗口重绘系统都会往消息队列里塞一条消息程序通过消息循环取出来再分发到窗口过程去处理。但游戏是连续的鱼每帧都要移动、炮弹每帧都要飞不能等用户动了才更新画面。所以捕鱼达人这样的工程必须让两套循环并行工作——一套循环负责“等系统消息”另一套负责“按固定节奏推进游戏逻辑”。对应到代码里最常见的做法是用SetTimer在窗口上挂一个定时器让系统每隔一段固定时间向窗口过程发送一条WM_TIMER消息。这样消息循环不用改游戏逻辑全部塞进WM_TIMER分支里移动鱼、移动炮弹、检测碰撞、刷新画面。也就是把游戏循环“伪装”成消息循环的一部分。这种做法的好处是结构简单消息和逻辑在同一个线程里不需要考虑同步问题课程设计这个规模的项目完全够用。2.2 从 WinMain 到窗口创建的关键调用链整个工程的入口是WinMain这也是 Win32 程序区别于控制台程序的第一道门槛。源码里通常分四步走注册窗口类、创建窗口、进入消息循环、处理窗口过程。核心代码大致是这样int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { WNDCLASS wc { 0 }; wc.hInstance hInstance; wc.lpfnWndProc WndProc; // 绑定窗口过程 wc.lpszClassName TEXT(FishHunterWindow); wc.hbrBackground (HBRUSH)GetStockObject(BLACK_BRUSH); wc.hCursor LoadCursor(NULL, IDC_HAND); RegisterClass(wc); // 第一步注册窗口类 HWND hwnd CreateWindow( TEXT(FishHunterWindow), // 窗口类名必须与上面一致 TEXT(捕鱼达人 - Windows程序设计课程设计), WS_OVERLAPPEDWINDOW, // 窗口样式带标题栏和边框 CW_USEDEFAULT, CW_USEDEFAULT, 900, 640,// 窗口大小与位置 NULL, NULL, hInstance, NULL); // 父窗口、菜单、实例句柄均无或默认 ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); // 触发第一次 WM_PAINT MSG msg; while (GetMessage(msg, NULL, 0, 0)) // 第二步进入消息循环 { TranslateMessage(msg); // 键盘消息转译 DispatchMessage(msg); // 分发到 WndProc } return 0; }这里要先解释清楚RegisterClass和CreateWindow的关系RegisterClass只是把窗口类的“模板”登记进系统真正把窗口创建出来、拿到窗口句柄HWND的是CreateWindow。写错lpszClassName或者忘记lpfnWndProc赋值是新手最容易出问题的地方前者导致 CreateWindow 失败返回 NULL后者导致窗口消息无人处理、一启动就退出。GetMessage在收到WM_QUIT时返回 0这是窗口通过PostQuitMessage发出的退出信号消息循环就在这里断开整个进程结束。2.3 消息处理的分流每个分支只干一件事窗口过程是静态函数用switch (message)把不同类型消息分到不同分支。捕鱼达人这里的关键分支有五个WM_CREATE里做资源初始化WM_TIMER里推进游戏逻辑WM_PAINT里重绘画面WM_LBUTTONDOWN里响应鼠标开炮WM_DESTROY里清理资源并退出。每个分支只做自己该做的事画面绘制只放在WM_PAINT分支逻辑推进只放在WM_TIMER分支互不越界模块化就清晰了。我一般会在WM_CREATE里启动定时器把帧率控制在每秒 30 帧左右case WM_CREATE: // 30帧/秒即每帧间隔33ms SetTimer(hwnd, TIMER_GAME, 33, NULL); InitGame(); // 初始化鱼群、炮弹、分数等全局数据 break; case WM_TIMER: UpdateGame(hwnd); // 推进一帧逻辑移动鱼、移动炮弹、检测碰撞 InvalidateRect(hwnd, NULL, TRUE); // 请求重绘 break; case WM_LBUTTONDOWN: // lParam 低16位是x坐标高16位是y坐标 FireBubble(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); break;WM_TIMER里那一句InvalidateRect是关键它不直接画图而是向系统声明“这个客户区需要重绘”系统随后合成一条WM_PAINT消息。为什么要拐这个弯因为BeginPaint/EndPaint必须在WM_PAINT里配对使用强制把绘制放在WM_PAINT分支能让 GDI 的资源分配与释放始终成对出现。直接拿GetDC画也没问题但课程设计里如果到处乱画、不用BeginPaint画面容易出现残留答辩时很减分。需要注意GET_X_LPARAM需要额外的头文件支持老代码里更常见的是LOWORD(lParam)和HIWORD(lParam)分开取坐标效果一样。窗口坐标默认是相对于客户区左上角的坐标系统开炮的瞄准点计算直接用它就行不需要二次换算。定时器 ID 用TIMER_GAME这种宏定义而不是裸数字如果后面要加第二个定时器逻辑上不会混淆。3. 让鱼动起来GDI 双缓冲、贴图动画与鱼群数据结构3.1 闪屏的根源与双缓冲的解法先做一个小实验你就懂闪屏从哪来了在WM_PAINT里先擦背景、再逐条画鱼画面会快速经历“全黑 → 有几条鱼 → 全黑 → 有几条鱼”的交替。因为每一次擦除重画都直接作用在屏幕可见的区域上人眼感知到的就是高频闪烁。闪屏的根源不是画得多慢而是“擦”和“画”之间隔了可见的时间窗口。双缓冲的原理是把画布从屏幕换到内存先在内存里创建一块与窗口客户区大小一致的位图所有的擦除、贴图、绘制都在这个内存 DC 上完成全部画完以后再用一条BitBlt把整个内存位图一次性拷到屏幕上。屏幕端看到的永远是一幅完整的画面闪烁自然消失。捕鱼达人这个工程里鱼的数量多、贴图切换频繁双缓冲是必须的不是可选项。实现上就三步初始化时创建内存 DC每帧绘制到内存 DC最后整体拷贝// 初始化在 WM_CREATE 或 InitGame 中调用 void InitBackBuffer(HWND hwnd) { HDC hdc GetDC(hwnd); g_memDC CreateCompatibleDC(hdc); // 内存DC g_memBmp CreateCompatibleBitmap(hdc, WINDOW_W, WINDOW_H); g_oldBmp (HBITMAP)SelectObject(g_memDC, g_memBmp); // 把位图选入内存DC ReleaseDC(hwnd, hdc); } // 绘制WM_PAINT 分支里 case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); // 第一步在内存DC上擦背景 RECT rc { 0, 0, WINDOW_W, WINDOW_H }; FillRect(g_memDC, rc, (HBRUSH)GetStockObject(BLACK_BRUSH)); // 第二步在内存DC上画所有鱼、炮弹、分数 DrawFishes(g_memDC); DrawBubbles(g_memDC); // 第三步一次性拷到屏幕 BitBlt(hdc, 0, 0, WINDOW_W, WINDOW_H, g_memDC, 0, 0, SRCCOPY); EndPaint(hwnd, ps); break; }CreateCompatibleDC创建的 DC 初始状态只有一个 1x1 像素的黑色位图所以必须马上用CreateCompatibleBitmap建一块真正大小的位图并把旧位图用SelectObject换掉。选入的同时会返回旧位图句柄要把它存起来程序退出时再SelectObject换回去、删除内存 DC 和位图否则 GDI 对象会泄漏。课程设计跑一晚上不关程序任务管理器里 GDI 对象数持续上涨基本都是这个原因。3.2 鱼群数据结构与运动参数鱼的属性不外乎坐标、方向、速度、贴图类型、存活状态这几样。捕鱼达人工程里用一个结构体数组管理鱼群数组长度固定鱼死亡后只改标志位而不重新分配内存避免频繁 new / delete 造成内存碎片。结构体大致长这样#define MAX_FISH 40 // 同屏最多40条鱼 #define FISH_TYPE_COUNT 4 // 4种鱼分值不同 typedef struct _Fish { int x, y; // 当前坐标 int speed; // 速度单位像素/帧 int dir; // 方向角度0~359 int type; // 鱼的类型决定贴图与分值 int frame; // 当前动画帧序号 bool alive; // 是否存活 bool captured; // 是否已被网住 } Fish; Fish g_fishes[MAX_FISH];运动更新就是一个极简的线性位移x speed * cos(dir)y speed * sin(dir)。这里方向用角度还是弧度要先统一C 语言的sin/cos接收的是弧度如果初始化时把dir按角度存每次更新前要先做dir * PI / 180换算忘了换算的话鱼的轨迹会完全乱掉朝左上走的鱼突然朝右下飞。这类问题在答辩调试时很折磨人建议结构体里面直接存弧度展示时再转角度给人看。边界处理是鱼群逻辑里最容易出细节 bug 的地方。我惯用的规则是鱼碰到左右边界就翻转方向dir 180 - dir碰到上下边界就反向dir -dir并让x/y强制回到边界内防止鱼卡在边界外一直来回穿越但画面看不到。速度参数按鱼的类型错开取值小鱼快、大鱼慢同屏速度差让画面有层次感也让玩家能优先打慢速的大鱼。3.3 动画帧切换与贴图选择鱼的贴图是一组序列帧本质上是几张连续动作的位图按顺序循环播放。资源里贴图命名常见的是fish1_0.bmp、fish1_1.bmp、fish1_2.bmp同一种鱼的几帧动作循环播放看起来鱼就在摆尾游动。帧切换逻辑就一行状态判断void UpdateFishFrame(Fish* f) { // 每4帧切换一次动画帧避免切换过快 static int frameCounter 0; if (frameCounter % 4 0) { f-frame (f-frame 1) % 3; // 3帧循环 } }这里有个容易被忽略的联动鱼朝左游和朝右游时贴图不能是同一张否则鱼是倒着游的。工程里常见的处理是准备两张方向相反的位图或者在贴图时用StretchBlt配合镜像变换。Win32 GDI 没有直接的镜像绘制函数只能在绘制时用SetWorldTransform做坐标变换或者干脆加载两张位图、按方向选择。课程设计按后一种方案做就行简单直接效果也不差。绘制函数每帧要对每条鱼调用一次贴图加载。这里有一个性能大坑LoadBitmap/LoadImage每条鱼每帧都调用一次程序不卡才怪。这些 API 是把位图从磁盘读进来再解码属于重型操作。正确的做法是在初始化时把所有贴图加载好存进一个HBITMAP数组游戏循环里只做SelectObject切换。SelectObject本身也有开销一条鱼一张位图反复选入也费时进阶做法是把同一种鱼的全部帧合并到一张大位图里绘制时用BitBlt的srX/srY参数取其中一帧。资源里如果已经把帧合成好了直接抄这个思路即可。绘制的深度排序也值得一提同屏鱼会互相遮挡DrawFishes里如果按数组下标顺序画后画的永远盖住先画的视觉上大鱼总是压在前面。简单处理是按鱼的 y 坐标从小到大排个序再画y 值小屏幕上方的鱼先画y 值大屏幕下方的后画就可以做出粗略的距离感。这个排序每帧做一次40 条鱼用冒泡都无所谓但能让画面观感提升一个档次。4. 从开炮到结算碰撞检测、计分与关卡数值4.1 碰撞检测为什么选圆形判定捕鱼达人的碰撞判定发生在网与鱼之间。用矩形判定最简单但网和鱼都是带弧度的贴图矩形判定在视觉误差上很明显鱼头和尾巴不在矩形内却被判定命中玩家朝鱼头打却因为尾巴蹭到矩形边缘判定打空观感和手感都不对。圆形判定是精确度和复杂度之间的最好平衡点成本低、误差小、代码直观。标准写法是判断两个圆心距离是否小于两圆半径之和为了省掉开根号的耗时通常比较距离平方bool CheckHit(int fishX, int fishY, int fishR, int netX, int netY, int netR) { int dx fishX - netX; int dy fishY - netY; int distSq dx * dx dy * dy; // 距离平方避免sqrt int hitSq (fishR netR) * (fishR netR); return distSq hitSq; }不要小看这个“不调用sqrt”的细节。每秒 30 帧同屏最多 40 条鱼最多同时存在几个网每帧做几百次距离判定。每一次 sqrt 都是依次浮点运算乘加运算和整数比较的耗时能差一个数量级。真实场景里你可能感觉不到差别但写成距离平方是比较好的工程习惯也方便面试或者答辩时讲性能优化思路。fishR和netR是预设在初始化阶段的数据不同鱼有不同的半径根据贴图尺寸估算即可原则是宁可略小于贴图可见轮廓也不要大出一圈导致命中反馈太“假”。4.2 炮弹飞行与网展开的坐标计算鼠标点击后炮弹从炮台位置出发朝鼠标位置直线飞行。炮台坐标固定在窗口底部中间鼠标点击位置存成目标点。炮弹每一帧沿着两点连线方向移动固定步长直到到达目标点然后原地展开成一张网。网的命中范围随时间逐渐扩大再收缩消失模拟撒网捕鱼的效果。炮弹与网的状态合在一个结构体里管理简化成typedef struct _Bubble { int x, y; // 当前位置 int targetX, targetY; // 鼠标点击的目标 int state; // 0飞行中 1网展开 2结束 int lifeFrame; // 剩余帧数到0则回收 int radius; // 网的当前半径 } Bubble;飞行状态就一行步进逻辑每帧按速度沿方向向量移动void UpdateBubble(Bubble* b) { if (b-state 0) { double dx b-targetX - b-x; double dy b-targetY - b-y; double len sqrt(dx * dx dy * dy); double step BUBBLE_SPEED; // 单位像素/帧 // 防止最后一帧越过目标点 if (len step) { b-x b-targetX; b-y b-targetY; b-state 1; // 到达开始撒网 b-lifeFrame NET_LIFE; // 网存在20帧 b-radius 10; } else { b-x (int)(dx / len * step); b-y (int)(dy / len * step); } } else if (b-state 1) { // 网的半径先扩大再收缩最后一定帧数后消失 b-radius 2; if (--b-lifeFrame 0) { b-state 2; // 回收该网 } } }BUBBLE_SPEED和NET_LIFE这类参数在设计阶段不可能一次定准需要边跑边调。炮弹飞太快玩家来不及瞄准飞太慢则打断操作节奏网的存在帧数太短则大鱼还没碰到就消失了太长则玩家可以预判提前撒网。合理的起始值是炮弹速度 18 像素/帧、网存在 20 帧左右再根据实际屏幕分辨率等比缩放。要注意不同分辨率的适配900x640 窗口下 18 像素/帧合适切到全屏或者较大窗口时同一速度会显得很慢可以考虑按窗口宽度做一次比例换算。4.3 计分规则与关卡数值怎么调捕鱼达人的计分核心是不同鱼有不同分值工程里用一个数组按类型索引查表是比写一堆if-else更整洁的做法// 4种鱼的分值按鱼类型索引 static const int FISH_SCORE[FISH_TYPE_COUNT] { 10, 20, 50, 100 }; // 同屏最大鱼数、鱼生成间隔帧、大鱼概率 static const int FISH_SPEED[FISH_TYPE_COUNT] { 6, 4, 3, 2 };鱼类的产生由生成策略控制。典型做法是每若干帧尝试生成一条鱼生成位置固定在屏幕左侧或右侧之外进入屏幕后再参与更新这样观众能看到鱼“游进来”的过程而不是凭空刷在画面里。生成哪个类型用加权随机控制小鱼 60%、中鱼 25%、大鱼 12%、极品鱼 3%这个比例决定游戏的经济节奏——如果大鱼概率太高分数膨胀玩家十几分钟就通关太低则打在鱼群上全是低分挫败感明显。炮弹的消耗与鱼的分值需要做平衡。常见做法是炮弹每次发射消耗 1 金币或者叫子弹数命中后返还鱼的分值净收益 命中分值 - 消耗。FISH_SCORE的最低档是 10消耗设为 1意味着玩家即使只打中最小鱼也有 9 点净收益游戏正向反馈建立起来了。这个平衡参数是课程设计答辩时老师最爱问的点能讲清“为什么消耗设 1 而不是设 5”背后的逻辑会比单纯演示游戏加分不少。关卡进阶可以按时间或者累计分数来调整鱼速和概率比如每 30 秒把大鱼概率上浮 2 个百分点、鱼速整体上调 1让后期刺激程度递增。5. 课程设计最常翻车的五个坑从闪退到闪屏的排查实录5.1 现象窗口一闪就退出根本没有游戏画面原因消息循环没有正确的退出条件或者窗口过程里WM_DESTROY分支缺失PostQuitMessage(0)没有被调用程序在收到窗口关闭消息后直接走默认处理退出了。还有一个高频原因是CreateWindow返回了 NULL窗口压根没创建成功ShowWindow之后就崩了。解决先在WinMain里判断CreateWindow的返回值NULL 就直接MessageBox报错退出。确认窗口过程里case WM_DESTROY: PostQuitMessage(0); return 0;分支存在。字符集导致的崩溃或者lpszClassName不一致也会造成CreateWindow失败见下面第 5.4 条。5.2 现象鱼动起来画面狂闪像老式日光灯一样原因没有双缓冲每帧直接对窗口 DC 擦背景再画鱼擦除与绘制之间的空白窗口被人眼捕捉到了。这是单缓冲绘制的必然结果不是驱动或系统问题。解决按第 3.1 节的方案做内存 DC BitBlt。注意WM_PAINT里画到g_memDC时所有绘制函数传的 DC 必须是g_memDC而不是hdc经常有人初始化时正确、绘制时漏传参数导致白忙一场。创建内存 DC 放在WM_CREATE或者首次WM_PAINT时只做一次千万不要放进每帧绘制代码里重复创建。5.3 现象鱼一多就卡成幻灯片帧率肉眼可见地下降原因有 90% 的可能是每帧绘制时反复调用LoadBitmap/LoadImage从磁盘加载贴图。这个操作从磁盘读数据、解析 BMP 文件头、分配 GDI 对象每一条鱼每帧都做一遍性能和内存都扛不住。另一个常见原因是贴图位图过大比如一张 1000x1000 的 BMP 直接用于缩放绘制StretchBlt的缩放开销远超BitBlt。解决初始化阶段把全部贴图加载完毕存入HBITMAP数组绘制只做SelectObject和BitBlt。若资源里有大图需要缩放显示预先用StretchBlt处理好生成一张目标尺寸的小图存内存避免每帧实时缩放。同屏鱼数设置为 40 以下超过这个数值视觉上也不会更丰满只会增加无意义开销。5.4 现象编译报错提示“无法从 ‘const char *’ 转换为 ‘LPCWSTR’”原因工程默认启用了 Unicode 字符集TCHAR被展开成wchar_t宽字符字符串字面量FishHunterWindow却还是窄字符。把窄字符串窄传给宽字符接口编译器直接报错。这是 Win32 程序新手最常见的第一道坎跟游戏业务逻辑完全无关。解决字面量字符串统一用TEXT()宏包裹例如TEXT(FishHunterWindow)。这样在 Unicode 编译选项下自动转成宽字符在多字节字符集下保持窄字符。另一种做法是改工程字符集为“使用多字节字符集”但这只是逃避问题换一台默认 Unicode 的机器又重新报错。用TEXT()宏最彻底运行库函数也尽量用_t前缀等。注意LPSTR与LPCWSTR是两种不同的指针类型绝不可以在它们之间直接强制转换。5.5 现象Debug 版本跑得好好的切到 Release 版本就崩原因90% 是数组越界或读未初始化的内存。Debug 模式下编译器会在数组边界插入安全检查堆内存会自动清零越界写不一定会立刻爆炸Release 模式下所有保护都拿掉栈里的垃圾值被当成数组下标或函数指针崩溃方式千奇百怪。解决排查鱼群数组和动画帧数组的下标。常见写法f-frame (f-frame 1) % 3看起来安全但如果f本身是野指针整个运算行为就不可预期。先在UpdateGame开头对每条鱼做alive检查再更新排除“已死亡的鱼还在运动”的路径。同时把结构体内所有字段显式初始化尤其是bool类型不初始化就读取会造成随机结果。Release 崩溃时用_CrtSetBreakAlloc在 Debug 下还原同样路径不可行时就在关键数组操作前加_ASSERTE断言直接暴露出问题行。6. 交付前的最后一课答辩演示、参数微调与机油打包演示效果好不好有时候不取决于代码写得多完善而是几个参数值合不合适。提交课程设计之前我会把FISH_SPEED整体调慢 12 像素/帧把大鱼概率拉高 5 个百分点。理由是答辩现场的机器性能未知鱼速过快加上机器卡顿容易出现一种“鱼在瞬移”的廉价感鱼稍慢一点动画帧切换的细节看得清楚鱼的贴图也更耐看。炮弹速度BUBBLE_SPEED保持稍快宁可打到目标点边上落空也不要让全场等待一颗炮弹慢慢飞——答辩时你要讲解逻辑注意力不在操作上慢炮弹会让你频繁打空场面很难看。打包方面Windows 程序设计课程设计最常见的交付形态是直接把 Visual Studio 工程拷给老师但更好的做法是生成 Release 版本把 exe 和资源文件放在同一个文件夹里整个文件夹打包提交。要确认 exe 里没有硬编码绝对路径比如C:\\Users\\xxx\\Desktop\\fish.bmp这种写死在LoadBitmap里的路径换一台机器就加载不出贴图。正规做法是把资源路径改成相对路径或把位图加到工程资源里用MAKEINTRESOURCE加载。基于动设备跑不起来通常就是这个问题。答辩演示的法则是“先跑一轮完整流程再做代码讲解”。先当着老师的面完整跑一局开炮、命中大鱼、分数跳动、网消失回收、新鱼生成让老师对这个工程的完整性建立印象然后才进入代码讲解。讲解时优先讲双缓冲、碰撞检测、状态机这几块因为这三点是你和普通抄工程同学拉开差距的地方。我被问得最多的问题就是“网和鱼的碰撞怎么判断的”只要你能当场把那个距离平方判断的代码指出来并画个示意基本就能过。资源打包时把全套 Win32 源码、贴图资源、可直接运行的 Release 工程放在一起方便你对照阅读和修改参数。建议先把程序完整跑通一遍再逐行改着玩把鱼的生成间隔改成 5 帧看看屏幕瞬间爆满是什么感受把双缓冲去掉体会一次闪屏再改回来。亲手把每个坑踩一遍答辩时老师问什么都答得上来。希望这份工程能帮你把课程设计稳稳落地也希望你改完参数做出属于自己的那版捕鱼达人。本文还有配套的精品资源点击获取