☰
基于Win32 API与GDI+的捕鱼达人游戏开发实战:C++课程设计完整方案
2026/10/20 13:34:30 网站建设 项目流程

简介:这是一份面向高校C++课程设计与Windows游戏开发初学者的实践项目源码,主题为经典小游戏“捕鱼达人”。项目以C++语言为基础,结合Windows API完成窗口创建、消息循环与事件响应,并借助GDI绘制游戏背景、鱼群、炮台与子弹等元素,涵盖面向对象设计、碰撞检测、得分计算、定时器与多线程等核心知识点,适合作为课程设计参考或自学练手案例。资源包共36个文件,包含10个bmp位图与1个jpg图片用于界面和游戏素材,6个ogg、2个mp3及1个wav音频文件负责背景音乐与音效,另有4个h头文件、1个cpp源文件及lib、dsp、dsw等工程配置与库文件,整体约7.42MB,结构完整可直接编译运行。目前已有1307人学习下载,读者可从中理解游戏主循环、资源加载与释放、消息处理函数编写及调试优化思路,是掌握C++与Windows图形编程的实用范例。

1. 从课程设计痛点切入:为什么选捕鱼达人做 Windows 程序设计

每年一到学期中段,做 C++ 大学课程设计的学生就开始头疼:题目太简单拿不出手,题目太难又怕做不完。我见过太多人最后交上来一个控制台版的学生管理系统,功能倒是能跑,但答辩时老师问一句“你的 Windows 程序设计体现在哪”就哑火了。捕鱼达人这个题目恰好卡在一个很舒服的位置——它逼着你用上 Windows 图形编程的核心机制,但业务逻辑又足够直观,不至于让你在算法上卡死。这套方案要解决的就是:用纯 C++ 和 Windows API 搭一个能跑、能玩、能讲清楚设计思路的小游戏,适合正在做课程设计、需要一份完整可参考实现的人。下面从需求、架构、模块到验收,一层层拆开。

2. 需求分析与技术选型:为什么不用游戏引擎而用原生 API

2.1 课程设计场景下的真实需求

先明确一件事:这不是商业游戏项目,不需要考虑跨平台、热更新、资源热加载这些工程化问题。课程设计的核心诉求是——展示你对 Windows 程序设计课程知识点的掌握程度。具体来说,需求可以拆成四层:

第一层是窗口与消息循环。你得有一个主窗口,能响应鼠标点击、键盘输入、窗口重绘、最小化恢复这些基本消息。这是 Windows 程序设计的骨架,老师一眼就能看出你有没有真正理解WndProc的调度逻辑。

第二层是图形绘制。捕鱼达人需要绘制背景、炮台、鱼群、子弹、金币数字。用 GDI 还是 GDI+?我的建议是 GDI+,因为支持透明 PNG 贴图,鱼和炮台的视觉效果会好很多,而且Graphics类的绘图接口比原生 GDI 的HDC操作更直观。

第三层是游戏逻辑。鱼的生成、游动、碰撞检测、子弹发射、金币结算,这些是业务核心。碰撞检测用矩形包围盒还是圆形?鱼是椭圆形的,用圆形检测更合理,但实现上矩形更简单。我一般会先用矩形做快速筛选,再用圆形做精确判断,兼顾性能和准确度。

第四层是数据持久化。课程设计通常要求有“存档”或“排行榜”功能,用简单的文本文件或二进制文件读写即可,不需要上数据库。

2.2 技术选型的三个关键决策

决策一:用 Win32 API 还是 MFC?很多教材用 MFC,因为它封装了窗口类、消息映射,写起来代码量少。但 MFC 的“黑匣子”属性太强,答辩时老师追问“消息循环在哪”“窗口过程怎么注册的”,用 MFC 的人往往答不上来。我建议用纯 Win32 API,虽然代码多写几十行,但每一行你都能讲清楚来龙去脉。而且 Win32 的CreateWindowEx、GetMessage、DispatchMessage这套流程,本身就是课程重点。

决策二:用 GDI 还是 GDI+?GDI 的BitBlt绘制位图很快,但不支持 Alpha 通道,鱼的 PNG 贴图会带黑边。GDI+ 的Graphics::DrawImage支持透明,而且有抗锯齿,视觉效果直接上一个档次。性能上,捕鱼达人这种同屏几十个精灵的规模,GDI+ 完全扛得住。唯一要注意的是 GDI+ 需要初始化GdiplusStartup,并在程序退出时GdiplusShutdown。

决策三:游戏循环用定时器还是独立线程?SetTimer是最简单的方案,在WM_TIMER里更新游戏状态并触发重绘。但定时器精度受消息队列影响,最小间隔通常只能到 15ms 左右,而且如果消息处理慢了,定时器会丢帧。更稳妥的做法是开一个独立线程跑游戏循环,用Sleep控制帧率,通过自定义消息通知主窗口重绘。不过对于课程设计,SetTimer足够用,代码也更简单。我一般会先用定时器把功能跑通,如果时间充裕再改成线程。

2.3 开发环境与依赖

  • 编译器:Visual Studio 2019 或 2022,用 C++17 标准
  • 图形库:GDI+(Windows SDK 自带,无需额外安装)
  • 资源格式:PNG 贴图 + WAV 音效(音效可用PlaySound播放)
  • 项目类型:Windows 桌面应用程序(不是控制台)

注意:GDI+ 的头文件是gdiplus.h,链接库是gdiplus.lib,在项目属性里要手动加上,否则编译会报GdiplusStartup未定义。

3. 总体架构设计:窗口层、游戏层、渲染层的职责划分

3.1 三层架构的模块划分

整个项目我习惯分成三个层次,每层职责清晰,方便调试和答辩时讲结构。

窗口层负责与 Windows 系统交互。核心是WinMain入口、WndProc窗口过程、消息循环。这一层不包含任何游戏逻辑,只做消息分发和窗口生命周期管理。比如WM_CREATE时初始化游戏对象,WM_TIMER时调用游戏更新,WM_PAINT时调用渲染,WM_DESTROY时清理资源。

游戏层是业务核心。包含GameManager类,管理鱼群、子弹、炮台、金币等所有游戏对象。每个对象有自己的更新逻辑:鱼按预设路径游动,子弹直线飞行,碰撞后触发结算。这一层不直接操作绘图接口,只维护数据状态。

渲染层负责把游戏层的状态画到屏幕上。包含Renderer类,封装 GDI+ 的Graphics对象,提供DrawFish、DrawBullet、DrawCannon等接口。渲染层从游戏层读取数据,但不修改数据。

三层之间的调用关系是:窗口层驱动游戏层更新,游戏层更新完后通知渲染层重绘。这样分层的好处是,如果以后想换成 Direct2D 渲染,只需要改渲染层,游戏逻辑不动。

3.2 核心数据结构设计

游戏对象用结构体或类表示,我倾向于用类,因为可以封装更新和绘制方法。以下是关键数据结构:

// 鱼类 class Fish { public: float x, y; // 当前位置 float speed; // 游动速度 int type; // 鱼的类型(小鱼/中鱼/大鱼) int reward; // 击杀奖励金币 float angle; // 游动方向 bool alive; // 是否存活 void Update(float dt); // 更新位置 void Draw(Graphics& g); // 绘制自己 }; // 子弹 class Bullet { public: float x, y; float vx, vy; // 速度分量 int damage; // 伤害值 bool alive; void Update(float dt); void Draw(Graphics& g); }; // 炮台 class Cannon { public: float x, y; // 炮台位置 float angle; // 炮口角度 int level; // 炮台等级(1-7) void RotateTo(float targetX, float targetY); // 转向鼠标 void Fire(std::vector<Bullet>& bullets); // 发射子弹 };

GameManager持有std::vector<Fish>、std::vector<Bullet>和一个Cannon对象。每帧更新时,遍历所有鱼和子弹,调用它们的Update方法,然后做碰撞检测。碰撞检测用圆形距离判断:

bool CheckCollision(const Bullet& b, const Fish& f) { float dx = b.x - f.x; float dy = b.y - f.y; float radius = f.type == 0 ? 20.0f : (f.type == 1 ? 35.0f : 50.0f); return (dx * dx + dy * dy) < (radius * radius); }

这里radius根据鱼的类型不同而不同,小鱼判定半径小,大鱼判定半径大。用平方比较避免开方运算,提升性能。

3.3 消息循环与游戏循环的衔接

WinMain里的消息循环是标准的 Windows 写法:

MSG msg; while (GetMessage(&msg, nullptr, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); }

游戏循环通过SetTimer触发,在WM_TIMER里调用GameManager::Update:

case WM_TIMER: if (wParam == TIMER_GAME_LOOP) { g_game.Update(0.016f); // 假设 60FPS,每帧约 16ms InvalidateRect(hwnd, nullptr, FALSE); // 触发重绘 } break;

InvalidateRect标记窗口需要重绘,系统随后会发送WM_PAINT消息。在WM_PAINT里调用渲染层绘制:

case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); Graphics graphics(hdc); g_game.Render(graphics); EndPaint(hwnd, &ps); } break;

这里有个细节:InvalidateRect的第三个参数FALSE表示不擦除背景,避免闪烁。背景擦除在WM_ERASEBKGND里处理,直接返回TRUE表示已处理,不调用默认的背景擦除。

提示:如果画面闪烁严重,检查是否在WM_PAINT里重复绘制了背景,或者InvalidateRect的bErase参数设成了TRUE。

4. 关键模块实现:从炮台转向到鱼群生成的具体代码

4.1 炮台跟随鼠标与子弹发射

炮台需要根据鼠标位置旋转角度。在WM_MOUSEMOVE里获取鼠标坐标,计算炮台到鼠标的向量,然后求角度:

case WM_MOUSEMOVE: { float mx = (float)GET_X_LPARAM(lParam); float my = (float)GET_Y_LPARAM(lParam); g_game.GetCannon().RotateTo(mx, my); } break;

RotateTo的实现:

void Cannon::RotateTo(float targetX, float targetY) { float dx = targetX - x; float dy = targetY - y; // atan2 返回弧度,范围 -PI 到 PI angle = atan2f(dy, dx); // 限制炮口角度在 -90 度到 90 度之间(炮台只能朝上半平面) if (angle > PI / 2) angle = PI / 2; if (angle < -PI / 2) angle = -PI / 2; }

atan2f比atan更安全,因为它能正确处理dx为 0 的情况。限制角度范围是因为捕鱼达人的炮台通常只能朝上方射击,不能转到屏幕下方。

发射子弹在WM_LBUTTONDOWN里触发:

case WM_LBUTTONDOWN: { g_game.GetCannon().Fire(g_game.GetBullets()); } break;

Fire方法根据炮台角度计算子弹速度分量:

void Cannon::Fire(std::vector<Bullet>& bullets) { Bullet b; b.x = x; b.y = y; float speed = 800.0f; // 子弹速度,像素/秒 b.vx = cosf(angle) * speed; b.vy = sinf(angle) * speed; b.damage = level; // 炮台等级越高,伤害越大 b.alive = true; bullets.push_back(b); }

子弹的Update方法每帧更新位置:

void Bullet::Update(float dt) { x += vx * dt; y += vy * dt; // 超出屏幕边界则销毁 if (x < 0 || x > SCREEN_WIDTH || y < 0 || y > SCREEN_HEIGHT) { alive = false; } }

这里dt是帧间隔时间,用0.016f表示 60FPS 下的每帧时长。用dt而不是固定步长,是为了让游戏在不同性能的机器上速度一致。

4.2 鱼群生成与路径控制

鱼群的生成需要控制频率和类型。我一般用一个生成计时器,每隔一定时间生成一条鱼:

void GameManager::Update(float dt) { spawnTimer += dt; if (spawnTimer >= spawnInterval) { spawnTimer = 0.0f; SpawnFish(); } // 更新所有鱼 for (auto& fish : fishes) { fish.Update(dt); } // 更新所有子弹 for (auto& bullet : bullets) { bullet.Update(dt); } // 碰撞检测 CheckCollisions(); // 清理死亡对象 CleanupDeadObjects(); }

SpawnFish随机选择鱼的类型和出生位置:

void GameManager::SpawnFish() { Fish f; f.type = rand() % 3; // 0:小鱼 1:中鱼 2:大鱼 f.reward = (f.type + 1) * 2; // 小鱼2金币,中鱼4金币,大鱼6金币 f.speed = 50.0f + rand() % 100; // 50-150 像素/秒 // 从左侧或右侧出生 if (rand() % 2 == 0) { f.x = -50.0f; f.y = 100.0f + rand() % 400; f.angle = 0.0f; // 向右游 } else { f.x = SCREEN_WIDTH + 50.0f; f.y = 100.0f + rand() % 400; f.angle = PI; // 向左游 } f.alive = true; fishes.push_back(f); }

鱼的Update方法根据角度移动:

void Fish::Update(float dt) { x += cosf(angle) * speed * dt; y += sinf(angle) * speed * dt; // 超出屏幕则销毁 if (x < -100 || x > SCREEN_WIDTH + 100) { alive = false; } }

为了让鱼游动更自然,可以给鱼加一个轻微的上下摆动,用正弦函数修改y坐标:

y += sinf(x * 0.01f) * 20.0f * dt;

这样鱼在水平游动的同时会有轻微的上下浮动,视觉效果更生动。

4.3 碰撞结算与金币系统

碰撞检测在CheckCollisions里遍历所有子弹和鱼:

void GameManager::CheckCollisions() { for (auto& bullet : bullets) { if (!bullet.alive) continue; for (auto& fish : fishes) { if (!fish.alive) continue; if (CheckCollision(bullet, fish)) { bullet.alive = false; fish.alive = false; coins += fish.reward; // 播放击中音效 PlaySound(L"hit.wav", nullptr, SND_ASYNC); break; // 一颗子弹只击中一条鱼 } } } }

金币系统很简单,用一个int coins变量记录当前金币数。炮台发射子弹时扣除金币,击中鱼时增加金币:

void Cannon::Fire(std::vector<Bullet>& bullets) { if (g_game.GetCoins() < level) return; // 金币不足,不能发射 g_game.AddCoins(-level); // 扣除金币 // ... 创建子弹 }

炮台等级越高,发射消耗的金币越多,但伤害也越高。这样形成一个简单的经济循环:打小鱼赚金币,攒够了升级炮台打大鱼。

注意:PlaySound播放 WAV 文件时,如果文件路径不对会静默失败。建议用绝对路径或把音频文件放在工作目录下。

5. 避坑与常见问题:消息处理、资源释放、性能优化

5.1 窗口重绘闪烁严重

现象:游戏运行时画面不停闪烁,鱼和炮台有拖影。

原因:WM_PAINT里先擦除了背景,再绘制新内容,两次操作之间屏幕会短暂显示空白。另外InvalidateRect的bErase参数如果为TRUE,系统会在WM_PAINT前发送WM_ERASEBKGND擦除背景。

解决:在WM_ERASEBKGND里直接返回TRUE,阻止默认的背景擦除。同时InvalidateRect的第三个参数传FALSE。如果闪烁依然严重,可以用双缓冲:先在内存 DC 里绘制完整画面,再一次性BitBlt到屏幕 DC。

5.2 GDI+ 对象未释放导致内存泄漏

现象:游戏运行一段时间后内存占用持续上升,最终卡顿或崩溃。

原因:GDI+ 的Image、Graphics、Pen、Brush等对象需要手动释放。如果每次绘制都new一个Image而不delete,内存会迅速耗尽。

解决:把图片资源在游戏初始化时一次性加载,存到std::map或std::vector里,绘制时直接引用,不要每帧重新加载。Graphics对象在WM_PAINT里创建,EndPaint后自动销毁,不需要手动释放。Pen和Brush如果频繁使用,也建议做成成员变量复用。

5.3 定时器精度不足导致游戏速度不稳定

现象:游戏在某些机器上跑得很快,在某些机器上很慢。

原因:SetTimer的精度受系统调度影响,实际触发间隔可能大于设定的毫秒数。如果游戏逻辑用固定步长更新,速度就会不一致。

解决:在WM_TIMER里用GetTickCount或QueryPerformanceCounter计算实际时间差,把dt传给Update方法。这样无论定时器实际间隔是多少,游戏速度都基于真实时间,保持稳定。

5.4 鼠标点击坐标与游戏坐标不一致

现象:点击屏幕上的鱼,子弹却飞向别处。

原因:WM_MOUSEMOVE和WM_LBUTTONDOWN返回的坐标是相对于窗口客户区的,但如果窗口有边框或标题栏,客户区坐标和屏幕坐标会有偏移。另外如果游戏做了缩放,坐标还需要换算。

解决:用GET_X_LPARAM和GET_Y_LPARAM获取客户区坐标,确保炮台位置和鼠标坐标在同一坐标系下。如果窗口大小固定,不需要额外换算。如果支持窗口缩放,需要在WM_SIZE里更新游戏世界的缩放比例。

5.5 鱼群数量过多导致帧率下降

现象:游戏后期鱼和子弹数量增多,画面明显卡顿。

原因:每帧遍历所有对象做碰撞检测,时间复杂度是 O(n*m),n 是子弹数,m 是鱼数。当 n 和 m 都达到几百时,计算量爆炸。

解决:用空间分割优化碰撞检测。把屏幕划分成网格,每个网格记录包含的鱼,子弹只检测所在网格及相邻网格的鱼。这样每次检测的对象数量大幅减少。对于课程设计,如果鱼的数量控制在 50 条以内,不优化也能跑满 60FPS。

6. 验收与答辩:怎么演示、怎么讲设计、怎么应对提问

6.1 演示流程设计

答辩演示通常只有 5 到 10 分钟,不能现场从头玩到通关。我建议提前准备一个演示脚本,按以下顺序展示:

第一步,启动程序,展示主窗口和初始界面。重点讲窗口的创建过程、消息循环的启动、GDI+ 的初始化。这一步能让老师确认你的程序是真正的 Windows 应用程序,而不是控制台程序。

第二步,移动鼠标,展示炮台跟随旋转。讲WM_MOUSEMOVE的消息处理、atan2f的角度计算、角度范围限制的逻辑。这是展示你理解消息机制的好机会。

第三步,点击发射子弹,展示子弹飞行和碰撞检测。讲子弹的速度分解、dt时间步长、圆形碰撞检测的数学原理。如果老师问“为什么不用矩形检测”,可以回答圆形更贴合鱼的形状,而且计算量相当。

第四步,展示金币结算和炮台升级。讲经济系统的设计思路:打小鱼赚金币,攒够升级炮台,打大鱼赚更多。这是展示你游戏设计思维的部分。

第五步,打开代码,展示三层架构的目录结构,讲窗口层、游戏层、渲染层的职责划分。这一步是答辩的核心,老师最关心的是你的设计思路,而不是功能多少。

6.2 常见答辩问题与应对

问题回答要点
消息循环的作用是什么?从消息队列取出消息,翻译后分发给窗口过程,驱动整个程序运行
WM_PAINT 和 InvalidateRect 的关系?InvalidateRect 标记区域无效,系统在适当时机发送 WM_PAINT 触发重绘
为什么用 GDI+ 而不是 GDI?GDI+ 支持透明通道和抗锯齿,视觉效果更好,API 更面向对象
碰撞检测的时间复杂度是多少?朴素实现是 O(n*m),可以用空间分割优化到接近 O(n+m)
游戏循环为什么用 dt 而不是固定步长?保证不同性能机器上游戏速度一致,避免帧率影响游戏体验
内存泄漏怎么排查的?用 Visual Studio 的内存快照对比,重点检查 GDI+ 对象的释放

6.3 代码规范与文档准备

答辩时老师可能会翻你的代码。我一般会做三件事:第一,给每个类写头文件注释,说明职责和关键方法;第二,给核心算法写行内注释,比如碰撞检测的数学公式;第三,准备一份一页纸的架构图,打印出来放在手边,讲到架构时直接指着图说。

代码命名要统一,类名用大驼峰,变量名用小驼峰,常量全大写。不要出现a、b、c这种无意义命名。函数长度控制在 50 行以内,超过就拆成子函数。这些细节不影响功能,但能体现你的工程素养。

从那以后我每次带课程设计,都要求学生先画架构图再写代码,先定接口再填实现。捕鱼达人这个题目不大,但五脏俱全,窗口、消息、绘图、逻辑、数据结构全都能覆盖到。把这套方案跑通,Windows 程序设计的核心知识点基本就串起来了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询