简介:这份面向高校C++课程设计与Windows游戏入门者的实践包,以“捕鱼达人”为主题,覆盖窗口创建、GDI绘图、消息循环、鼠标事件、定时器与多线程等Windows程序设计关键环节。项目采用面向对象组织鱼类、子弹与游戏主逻辑,涉及移动模拟、碰撞检测、得分计算和图片音频资源加载释放,适合在Visual Studio中对照调试,理解从界面绘制到玩法闭环的完整流程。压缩包共36个文件,约7.42MB,包含bmp图像、ogg/mp3/wav音频、txt配置说明、h头文件与cpp源码等,便于按模块查阅素材与实现。已有1309人学习,可作为课程设计参考、Windows API练手案例与游戏逻辑拆解样本,帮助读者掌握资源管理、事件响应和基础性能优化思路。
1. 一个能跑满一学期的选题:为什么捕鱼达人适合当 Windows 程序设计课程设计
每年到课程设计选题那几周,总有人问同一个问题:用 C++ 写个 Windows 小游戏,选什么题目既不烂大街、又能把课程要求的知识点全部用上。捕鱼达人这个题目我前后带过几轮,它的价值不在于玩法有多复杂,而在于它是一个"全链路"的题:窗口创建与注册、消息循环、鼠标交互、定时器驱动、GDI 绘图、位图资源、内存与对象生命周期,一个都躲不掉。你没法只写个控制台循环糊弄过去,也没法像用引擎那样把底层全屏蔽掉。这篇内容面向两类人:一类是要交 Windows 程序设计课程设计、手里只有一份题目清单还没动手的同学;另一类是要出一份可交付小游戏方案的从业者,想找一个结构清晰、代码量可控的练手模板。接下来按需求边界、技术选型、架构分层、关键模块、避坑排查、验收答辩这条线,把整套东西拆开讲。
2. 需求拆解与技术选型:在原生窗口上把鱼群跑起来的四件事
2.1 功能边界:一个学期能交付的最小闭环
课程设计最常见的翻车方式不是写不出来,而是想得太大。捕鱼达人如果按商业版本来对标,要做的有渔场分房、多炮台协同、鱼潮节奏、活动奖励、服务端结算,这些跟课程设计没有半点关系。我一般建议把范围压到这个闭环:一个固定大小的窗口,底部中央一门炮台,鼠标移动改变炮口朝向,左键点击发射子弹,鱼群从屏幕左侧或右侧游入并按路径行进,子弹与鱼发生碰撞后按概率判定是否捕获,捕获则加金币、未捕获则子弹消失,金币不足时禁止开炮,金币归零或时间到进入结算界面,最高分写入本地文件。
这个闭环里天然包含了课程大纲要求的知识点,而且是"必须用"而不是"可以凑":
| 知识点 | 在项目里的落点 | 是否可替代 |
|---|---|---|
| 窗口类注册与创建 | WinMain 中 RegisterClassEx / CreateWindowEx | 不可替代 |
| 消息循环与窗口过程 | WndProc 处理 WM_PAINT / WM_TIMER / WM_LBUTTONDOWN | 不可替代 |
| GDI 绘图 | 双缓冲位图、BitBlt、TransparentBlt、文字输出 | 可用 GDI+ 替代 |
| 定时器 | SetTimer 驱动逻辑帧,timeGetTime 计算时间差 | 可用高精度定时器替代 |
| 鼠标交互 | 客户区坐标换算、炮口角度计算 | 不可替代 |
| 资源管理 | 位图资源加载与 GDI 对象释放 | 不可替代 |
| 文件读写 | 最高分、金币存档 | 可用注册表替代 |
表里最后一列的"可替代"其实是答辩时的加分项:老师问"你为什么不用 Direct2D",你能说清 GDI 在这个规模下够用、且更贴合课程内容,比含糊过去强得多。
功能边界定完之后,还要定一个"不做清单",写在设计文档第一页。我通常写四条:不做联网、不做多炮台、不做粒子特效、不做关卡编辑器。这四条一旦写进文档,后面无论谁提需求都可以指着它挡回去,也避免中期检查时被追问"你的排行榜呢"。
2.2 技术选型:GDI 双缓冲、GDI+ 还是 Direct2D
选型这块争议最多。三种方案的差别不在"谁更先进",而在"你愿不愿意为它多花两周时间"。
GDI 是 Win32 自带的老接口,API 简单,BitBlt、StretchBlt、TransparentBlt 就能覆盖捕鱼达人需要的全部绘制动作。缺点是不支持 alpha 混合,鱼图的透明边要靠 TransparentBlt 指定透明色,边缘会有锯齿;旋转炮台只能靠预生成多角度位图,或者干脆用 Polygon 画一个简笔炮台。
GDI+ 在 GDI 之上补了抗锯齿、矩阵变换和 PNG 的 alpha 通道,炮台旋转一句 Graphics::RotateTransform 就能搞定,鱼的贴图也能保留半透明边缘。代价是初始化要 GdiplusStartup,绘制速度比纯 GDI 慢一截,而且不少教材的课程设计章节根本不涉及 GDI+,老师看代码时会更关注你有没有用对 GDI 基础对象。
Direct2D 性能最好,但引入了 COM 接口、设备丢失恢复、DXGI 交换链这些概念,对一个两周要交活的课程设计来说属于过度设计,除非你的题目本身就是"Direct2D 渲染入门"。
我的默认选择是 GDI + 双缓冲,理由是三点:第一,捕鱼达人的绘制量很小,同屏鱼不超过 30 条、子弹不超过 20 发,GDI 完全跑得动;第二,双缓冲本身就是一个能讲清楚的知识点,答辩时"为什么闪烁、双缓冲怎么解决"是一道标准题;第三,代码量最少,能把时间留给碰撞和概率逻辑。
提示:如果你的老师明确要求使用 GDI+ 或要求支持 PNG 透明贴图,那就选 GDI+,但主循环和双缓冲的结构完全一样,只是把绘制调用换掉,不要为了换而重写整个框架。
2.3 窗口框架与消息循环的骨架
所有后续模块都挂在这个骨架上,所以骨架必须先跑通、再往里填肉。最小可运行版本只需要一个窗口、一个 WM_PAINT、一个 WM_TIMER 和一个 WM_LBUTTONDOWN。下面这段是去掉业务逻辑后的框架,实际项目里在标注的位置插入模块调用:
// main.cpp —— Win32 窗口框架骨架 #include <windows.h> #include <timeapi.h> #pragma comment(lib, "winmm.lib") const int WND_W = 1024; const int WND_H = 640; const UINT_PTR TIMER_GAME = 1; const int FRAME_MS = 16; // 约 60 帧,够用且不吃 CPU static DWORD g_lastTick = 0; LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam); int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int nCmdShow) { WNDCLASSEX wc = { sizeof(WNDCLASSEX) }; wc.style = CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.hbrBackground = (HBRUSH)GetStockObject(BLACK_BRUSH); wc.lpszClassName = L"FishHunterWnd"; RegisterClassEx(&wc); // 用 AdjustWindowRect 把客户区锁定成 1024x640,避免绘图坐标被边框干扰 RECT rc = { 0, 0, WND_W, WND_H }; AdjustWindowRect(&rc, WS_OVERLAPPEDWINDOW, FALSE); HWND hWnd = CreateWindowEx(0, L"FishHunterWnd", L"捕鱼达人", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, rc.right - rc.left, rc.bottom - rc.top, nullptr, nullptr, hInst, nullptr); ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd); MSG msg; while (GetMessage(&msg, nullptr, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return (int)msg.wParam; }这段骨架里有三个容易被忽略的细节。第一个是 AdjustWindowRect,它把"窗口尺寸"换算成"客户区尺寸",如果不做这一步,你按 1024×640 画的背景图会被标题栏和边框挤掉一截,鱼的路径坐标全部错位。第二个是 CS_HREDRAW | CS_VREDRAW,它保证窗口尺寸变化时整窗重绘,图省事可以直接把窗口设成不可调大小,从根上回避坐标换算。第三个是 TIMER_GAME 这个 ID,全局定时器 ID 要集中定义,后面加暂停、加结算动画时不会撞号。
WndProc 的分支也一样,先只留四个,其余一律走 DefWindowProc:
LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: // 资源加载、双缓冲 DC 创建、游戏状态初始化全部在这里 // InitGame(hWnd); SetTimer(hWnd, TIMER_GAME, FRAME_MS, nullptr); g_lastTick = timeGetTime(); return 0; case WM_TIMER: if (wParam == TIMER_GAME) { DWORD now = timeGetTime(); float dt = (now - g_lastTick) / 1000.0f; g_lastTick = now; if (dt > 0.1f) dt = 0.1f; // 拖动窗口后会积压消息,钳一下防止鱼瞬移 // UpdateGame(dt); // InvalidateRect(hWnd, nullptr, FALSE); } return 0; case WM_LBUTTONDOWN: // OnFire(LOWORD(lParam), HIWORD(lParam)); return 0; case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 从内存 DC 一次性贴到窗口,避免闪烁 // BitBlt(hdc, 0, 0, WND_W, WND_H, g_hdcMem, 0, 0, SRCCOPY); EndPaint(hWnd, &ps); return 0; } case WM_DESTROY: KillTimer(hWnd, TIMER_GAME); // ReleaseGame(); // 释放位图、DC、字体 PostQuitMessage(0); return 0; } return DefWindowProc(hWnd, msg, wParam, lParam); }把 WM_CREATE 和 WM_DESTROY 当成一对"配对函数"来写,每个在 WM_CREATE 里 new 或 Create 出来的东西,都必须在 WM_DESTROY 里有一行对应的 delete 或 DeleteObject。这个习惯不是洁癖,GDI 对象泄漏是 Windows 图形程序最典型的黑匣子问题,跑三分钟卡死一次,你根本不知道去哪儿找。
3. 总体架构设计:四层结构与四个核心对象的职责划分
3.1 分层与模块职责
一个课程设计不需要 DDD 那种复杂度,但完全平铺在一个 WndProc 里也撑不住——等你写到鱼群生成、碰撞判定、金币结算、暂停恢复,一个函数会膨胀到七八百行,改一个参数要翻半天。我一般按职责切四层,每个模块一个头文件加一个源文件,答辩讲结构时一目了然:
| 层 | 模块名 | 职责 | 不做什么 |
|---|---|---|---|
| 平台层 | AppMain | WinMain、窗口注册、消息循环、定时器 | 不含任何游戏规则 |
| 平台层 | GameWindow | WndProc 分发、输入事件转换为游戏事件 | 不直接画图、不计分 |
| 渲染层 | Renderer | 双缓冲 DC 管理、位图加载、Sprite 绘制、文字绘制 | 不改游戏数据 |
| 逻辑层 | GameWorld | 对象容器、更新循环、碰撞、生成调度、状态机 | 不碰 HDC |
| 逻辑层 | Economy | 金币加减、炮倍校验、捕获概率结算 | 不做绘制 |
| 数据层 | ConfigLoader | 鱼种参数表、路径参数、存档读写 | 不含业务判断 |
这条边界最核心的一条规矩是:逻辑层不许出现 HDC,渲染层不许修改 Float 坐标。做这个约束的直接收益是,你可以把逻辑层单独拿出来跑一个控制台测试,不看画面就能验证"1000 发子弹打中 100 条鱼,金币收支是否平衡"。没有这条约束,调数值就只能靠一遍遍盯着屏幕看,效率极低。
3.2 四个核心对象与数据表设计
捕鱼达人的实体不多,就四类:鱼、子弹、炮台、玩家。把它们定义清楚,后面所有逻辑都是围绕这四个结构体做增删改查。下面是我常用的定义,字段不多但够用:
// GameTypes.h —— 核心实体定义 #include <vector> #include <string> struct Fish { int type; // 鱼种索引,对应参数表下标 float x, y; // 基准位置(客户区像素) float vx, vy; // 基准速度(像素/秒) float phase; // 摆动的当前相位 float swayAmp; // 摆动幅度(像素) float swaySpeed; // 摆动角速度 float radius; // 碰撞半径 int score; // 捕获得分 float baseProb; // 基础捕获概率 0~1 bool alive; bool fromLeft; // 从哪一侧进入,决定朝向 }; struct Bullet { float x, y; float vx, vy; int power; // 炮倍:1/2/3/5,影响消耗与概率修正 bool alive; }; struct Cannon { float x, y; // 炮台锚点,固定在底部中央 float angle; // 炮口弧度,0 为 +X 方向 int power; // 当前炮倍 }; struct GameState { int coins; int score; int levelTimeLeft; // 剩余秒数 enum { MENU, PLAYING, PAUSED, GAMEOVER } phase; };参数表不写死在代码里,而是用一张静态数组存鱼种数据,这样调平衡只改一行数字,不用动逻辑:
| 鱼种 | 分值 | 基础概率 | 碰撞半径 | 速度(px/s) | 出现权重 |
|---|---|---|---|---|---|
| 小黄鱼 | 2 | 0.35 | 22 | 90 | 40 |
| 蓝刀鱼 | 5 | 0.22 | 28 | 120 | 25 |
| 河豚 | 10 | 0.12 | 36 | 70 | 18 |
| 魔鬼鱼 | 20 | 0.06 | 46 | 60 | 10 |
| 金枪鱼 | 50 | 0.03 | 54 | 150 | 6 |
| 鲨鱼 | 100 | 0.015 | 70 | 45 | 1 |
这张表是整个游戏经济的核心。血泪经验是:先定金币收支再定美术。一个新手最容易犯的错是先做鱼图、再随手填分值,结果一局下来要么金币暴涨(玩家无限开炮),要么十分钟打不中一条(演示时场面尴尬)。正确的顺序是先估一个期望值——假设一局 120 秒、平均每秒发 3 发 1 倍炮、每发消耗 1 金币,总消耗 360 金币,那么整局的期望产出应该略低于 360,比如 330 到 350,游戏才有张力。
3.3 主循环与状态机:定时器驱动,时间差结算
主循环这块有两种写法,一种是在 WinMain 里 while 死循环加 PeekMessage,另一种是用 SetTimer 让系统把控制权交回来。课程设计里我更推荐后者,因为 PeekMessage 死循环如果没写好,窗口一拖动就假死,你在答辩现场会非常被动。
定时器驱动有个坑:WM_TIMER 的精度只有 15.6 毫秒左右,而且不保证按时触发,你设 16 毫秒它可能 20 毫秒才来一次,也可能因为消息积压连来两次。所以绝对不能写f.x += f.vx * 0.016f这种"按帧算"的代码,必须记录两次 tick 的真实时间差:
// 时间差驱动的更新,与帧率解耦 void GameWorld::Tick(float dt) { if (state.phase != GameState::PLAYING) return; UpdateCannon(cannon, mouseX, mouseY); // 炮口跟随鼠标 UpdateBullets(dt); // 子弹位移 + 越界回收 UpdateFishes(dt); // 鱼群位移 + 摆动 + 出屏回收 SpawnScheduler(dt); // 按节奏生成新鱼 ResolveCollisions(); // 碰撞与捕获结算 CheckGameOver(dt); // 倒计时与金币归零判定 }时间差驱动的直接好处是:无论帧率怎么抖,鱼的移动速度在世界坐标里恒定,玩家看到的画面不会忽快忽慢。dt上限钳到 0.1 秒也不能省,因为拖动窗口时 Windows 会阻塞消息循环,松手瞬间可能积压七八条 WM_TIMER,不钳的话所有鱼会瞬移一大段,看起来像穿模。
状态机只留四个态:MENU、PLAYING、PAUSED、GAMEOVER。所有输入和更新都先判断当前状态,比如 PAUSED 时只响应恢复键和退出键,不更新任何逻辑。这个设计看起来多余,但答辩时一定会有人问"你怎么做暂停",如果你回答"我还没做",那是个明显的扣分点;反过来,四个状态加一个 switch 只要二十行代码,性价比极高。
4. 关键模块实现:从炮台瞄准到捕获结算的五段代码
4.1 炮台瞄准:把鼠标坐标换算成炮口角度
炮台跟随鼠标是捕鱼达人里第一个能做出"手感"的地方。核心就一个 atan2f,但细节不少:坐标要以炮台为原点、要把角度限制在上半圆、要处理鼠标落在炮台正下方时的边界情况。
// 炮口跟随鼠标,限制在 [180°, 360°] 的上半圆内 void UpdateCannon(Cannon& c, int mx, int my) { float dx = (float)mx - c.x; float dy = (float)my - c.y; // 鼠标离锚点太近时角度抖动剧烈,直接保持不变 if (dx * dx + dy * dy < 25.0f) return; float a = atan2f(dy, dx); // 结果范围 (-π, π] const float PI = 3.14159265f; if (a > 0.0f && a < PI * 0.5f) a = 0.0f; // 右下 → 贴右边水平 else if (a >= PI * 0.5f) a = PI - 0.001f; // 左下 → 贴左边水平 else if (a < -PI * 0.5f) a = -PI + 0.001f; c.angle = a; }这里有个坐标系常识必须记住:Windows 客户区坐标的 y 轴向下为正,所以 atan2f(dy, dx) 算出来的角度在屏幕上看起来是"顺时针为正"。炮台在底部中央 (512, 600),鼠标在上方时 dy 是负数,角度落在 (-π, 0) 区间,正好对应朝上的半圆。初学者经常按数学坐标系去想,结果炮口方向上下颠倒,调半天以为是 atan2 的 bug。
发子弹的函数就三行有效代码,但炮口偏移量要单独讲。子弹的出生点必须在炮口前端,不能是炮台中心,否则子弹会先叠在炮台上再飞出去,视觉上像"从肚子里吐出来":
// 发射子弹:消耗金币、校验余额、生成弹体 bool FireBullet(const Cannon& c, std::vector<Bullet>& bullets, int& coins) { if (coins < c.power) return false; // 金币不足,拒绝开炮 coins -= c.power; Bullet b; const float MUZZLE = 56.0f; // 炮管长度 b.x = c.x + cosf(c.angle) * MUZZLE; b.y = c.y + sinf(c.angle) * MUZZLE; b.vx = cosf(c.angle) * BULLET_SPEED; // BULLET_SPEED 约 900 px/s b.vy = sinf(c.angle) * BULLET_SPEED; b.power = c.power; b.alive = true; bullets.push_back(b); return true; }MUZZLE 和 BULLET_SPEED 这两个常量建议放进配置头文件,答辩演示时可以现场改数值展示"参数化",比重启程序讲十分钟有用。
4.2 鱼群路径:正弦摆动比贝塞尔曲线更好用
鱼怎么游,决定了画面像不像捕鱼达人。真正的商业版本用的是路径编辑器加样条曲线,课程设计没必要。我用的是一个更省事的模型:每条鱼有一个基准速度沿 X 轴移动,同时叠加一个 Y 方向的正弦摆动,摆动相位各自随机。这样同一批鱼不会整齐划一地平移,看起来有生气。
// 鱼的更新:X 匀速 + Y 正弦摆动,出屏后标记回收 void UpdateFish(Fish& f, float dt) { f.x += f.vx * dt * (f.fromLeft ? 1.0f : -1.0f); f.phase += f.swaySpeed * dt; // 到达对侧边界外 100px,视为离场 if (f.fromLeft && f.x > WND_W + 100.0f) f.alive = false; if (!f.fromLeft && f.x < -100.0f) f.alive = false; }绘制时实际的 y 是f.y + sinf(f.phase) * f.swayAmp,注意这个偏移只在绘制和碰撞时算,不回写进 f.y。为什么?如果回写,摆动位移会被累积进基准位置,跑十几秒后鱼群会整体漂到屏幕外。这个坑我第一次写的时候就踩了,鱼越游越往上飘,一开始以为是坐标算错,后来才发现是"状态被渲染污染"。所以逻辑层和渲染层分离那条规矩不是教条,它挡的就是这类问题。
生成调度用一个简单的计时器加权重抽签,比固定间隔刷鱼自然得多:
// 生成调度:基础间隔随波次递减,鱼种按权重抽签 void SpawnScheduler(float dt, float& timer, int wave) { timer -= dt; if (timer > 0.0f) return; // 波次越高,出鱼越快,但不低于 0.35 秒 timer = max(0.35f, 1.2f - wave * 0.08f); int pick = rand() % 100; int acc = 0; for (int i = 0; i < FISH_TYPE_COUNT; ++i) { acc += g_fishTable[i].weight; if (pick < acc) { SpawnOneFish(i); break; } } }权重表加起来必须正好是 100,否则会出现"某种鱼永远刷不出来"。写完一定要打印一次累加值做校验,这种小错误在演示时很难发现,但评分老师盯三分钟就会发现屏幕上来来回回只有三种鱼。
4.3 碰撞检测与捕获结算:先判命中,再摇概率
捕鱼达人的碰撞分两步,一步是几何命中,一步是概率捕获,两步分开写是有意为之。几何命中用圆与圆的距离平方比较,开平方都没必要,性能上无压力但逻辑更干净:
// 几何命中:圆-圆,避免开方 bool HitTest(const Bullet& b, const Fish& f) { float fy = f.y + sinf(f.phase) * f.swayAmp; // 用绘制位置参与判定 float dx = b.x - f.x; float dy = b.y - fy; float r = f.radius + BULLET_RADIUS; return (dx * dx + dy * dy) <= (r * r); }概率捕获这一段是课程设计里最容易被问倒的地方,因为它涉及"随机"和"可复现性"。我用的是基础概率乘以炮倍修正,并且留一个上限:
// 捕获判定:炮倍越高概率越高,但封顶 95% bool TryCatch(const Bullet& b, const Fish& f) { float p = f.baseProb * (1.0f + 0.15f * (b.power - 1)); if (p > 0.95f) p = 0.95f; int roll = rand() % 10000; // 万分之一精度 return roll < (int)(p * 10000.0f); }为什么用 10000 而不是 100?因为鲨鱼的基础概率只有 0.015,用rand() % 100的话它恒等于 1%,再乘上炮倍修正也是错的——精度根本不够。这种数值精度问题在演示场上不会立刻暴露,但你答辩时说"鲨鱼概率 1.5%",老师让你现场打二十发验证,结果一发不中或者中了两发,都会很尴尬。
结算函数负责把命中结果转发成金币变化,注意捕获成功要把鱼标记为 dead 并从容器里移除,未捕获则只销毁子弹。遍历时不要边遍历边 erase,用 remove_if 或者反向遍历,否则迭代器失效是典型的玄学崩溃——有时候跑一百次都没事,演示到一半直接退出。
4.4 双缓冲绘图:把闪烁从根上掐掉
不夸张地说,捕鱼达人课程设计里最常见的扣分点就是画面闪。原因很简单:你在 WM_PAINT 里一边画背景一边画鱼,屏幕逐条刷新,人眼就能看到中间状态。解决办法是全部画到内存 DC,最后一次性 BitBlt 贴到窗口:
// 初始化:创建与窗口兼容的内存 DC 和位图 void Renderer::Init(HWND hWnd, int w, int h) { HDC hdc = GetDC(hWnd); m_memDC = CreateCompatibleDC(hdc); m_memBmp = CreateCompatibleBitmap(hdc, w, h); m_oldBmp = (HBITMAP)SelectObject(m_memDC, m_memBmp); ReleaseDC(hWnd, hdc); } // 每帧:先铺背景,再依次画实体,最后整块贴出 void Renderer::Present(HDC hdcWindow, GameWorld& world) { BitBlt(m_memDC, 0, 0, WND_W, WND_H, m_bgDC, 0, 0, SRCCOPY); for (const Fish& f : world.fishes) DrawFish(m_memDC, f); for (const Bullet& b : world.bullets) DrawBullet(m_memDC, b); DrawCannon(m_memDC, world.cannon); DrawHud(m_memDC, world.state); BitBlt(hdcWindow, 0, 0, WND_W, WND_H, m_memDC, 0, 0, SRCCOPY); }三个细节值得写进设计文档。第一,m_oldBmp必须保存 SelectObject 的返回值,销毁时 SelectObject 回去再 DeleteObject,否则位图被 DC 占用,删不掉,跑久了句柄耗尽。第二,背景图不要每帧都从文件重画,预加载到 m_bgDC 里一次就够。第三,鱼图如果有透明背景,用 TransparentBlt 并把透明色指定为品红(RGB 255,0,0),比手工做掩码简单得多;代价是鱼边缘会有品红镶边,美术上把背景色改成和海水接近的颜色能缓解。
绘制顺序也要固定:背景 → 远景鱼 → 近景鱼 → 子弹 → 炮台 → HUD 文字。顺序反了会出现子弹被鱼盖住、金币数字被炮台压住的视觉错误,这种问题不改代码逻辑,只调顺序,但没人提醒你可能会找一下午。
5. 避坑与排查:五个让捕鱼达人当场翻车的问题
5.1 画面闪烁与残影:双缓冲做了一半
现象:拖动窗口时整屏白闪,或者鱼的身后拖一条长尾巴。
原因:前者是没做双缓冲,WM_PAINT 里直接用窗口 DC 逐条绘制;后者是做了双缓冲,但每次 Present 前没重铺背景,上一帧的像素被保留下来了。还有一种隐蔽情况:WM_ERASEBKGND 里返回了 TRUE 却没有任何绘制动作,背景变成随机残留。
解决:WM_PAINT 之前的 WM_ERASEBKGND 直接return 1;告诉系统"背景我自己管",然后在 Present 第一步无条件 BitBlt 背景图。如果用了位图背景,就别再设置wc.hbrBackground,两套背景会打架。
5.2 鱼越游越快:定时器精度被当成了帧率
现象:机器性能好的时候鱼正常,换台笔记本鱼快得像开加速器;或者拖一下窗口,松手瞬间所有鱼瞬移。
原因:代码里写死了0.016f的固定步长,而 WM_TIMER 的实际间隔随系统负载浮动;加上消息积压时连发多条 WM_TIMER,逻辑被重复推进。
解决:统一改成时间差驱动,每次 tick 用 timeGetTime 计算真实间隔,并且把 dt 钳在 0.1 秒以内。同时把"逻辑更新"和"重绘"解耦——逻辑在 WM_TIMER 里跑,重绘用 InvalidateRect 触发,不要每条消息都直接 BitBlt 到窗口。
5.3 跑三分钟就卡死:GDI 对象泄漏
现象:刚启动很流畅,两三分钟后帧率断崖式下跌,任务管理器里 GDI 对象数一路涨。
原因:在绘制函数里反复 CreatePen、CreateFont、CreateCompatibleBitmap 却不 DeleteObject。GDI 有进程级句柄上限(默认一万个),涨到顶程序直接画不出来。像DrawHud里每次 new 一个字体来画金币数字,是这类问题的重灾区。
解决:所有 Pen/Brush/Font/Bitmap 一律在 Init 阶段创建、存成成员、在 Release 阶段按"先 SelectObject 回原对象、再 DeleteObject"的顺序销毁。临时对象用 GetStockObject 拿系统预置的画刷,那个不用你删。
5.4 炮口方向错乱:坐标系和 DPI 缩放
现象:炮口指向和鼠标位置差 180 度,或者在高分屏笔记本上鼠标位置和炮口偏差越来越大。
原因:前者是把 Windows 的 y 轴向下当成了数学坐标系的向上;后者是系统 DPI 缩放 125% 时,lParam 拿到的是逻辑坐标,而你的绘制用的是物理像素。
解决:在 WinMain 开头调用SetProcessDPIAware()声明进程 DPI 感知,让坐标体系统一;鼠标坐标统一用GET_X_LPARAM/GET_Y_LPARAM取,不要自己拆 HIWORD 和 LOWORD(负数坐标会出错)。炮口角度那一块,用 cos/sin 画一条测试线验证方向,比盯着屏幕猜快得多。
5.5 演示现场加不上分:概率参数和存档路径
现象:自己电脑上打得挺爽,交上去老师一运行,打了五分钟金币没涨;或者最高分每次重启都归零。
原因:前者多半是概率参数偏向"防御性"设置(怕玩家赢得太容易,把 baseProb 全线调低),或者权重表累加不等于 100 导致只刷低分鱼;后者是存档写进了程序运行目录,而老师是在只读目录或者别的路径下打开的。
解决:概率表先在控制台模式下跑一万次蒙特卡洛验证期望收支,再调美术;存档统一写到%APPDATA%下的自建目录,用 SHGetFolderPath 拿路径,别用相对路径"save.dat"。
6. 验收与答辩:三分钟演示脚本与五道必答题的应对
验收前一晚不要再去加功能,把所有时间花在"能不能稳定跑完三分钟"上。我一般准备一段固定的演示脚本,按下面的顺序走,每一步都能对应到课程知识点,讲解和代码能对得上:
| 演示环节 | 操作 | 观察点 | 对应知识点 |
|---|---|---|---|
| 启动与窗口 | 双击 exe | 窗口尺寸正确、无白屏、背景完整 | 窗口注册、AdjustWindowRect |
| 交互响应 | 移动鼠标、点击开炮 | 炮口平滑跟随,子弹从炮口飞出 | 消息处理、坐标换算、atan2 |
| 动态绘制 | 观察鱼群游动 30 秒 | 无闪烁、无残影、无瞬移 | 双缓冲、时间差驱动 |
| 概率结算 | 切换 1 倍和 5 倍炮各打 20 发 | 高倍炮更容易捕获,金币变化合理 | 概率模型、数值平衡 |
| 边界情况 | 金币打到 0、暂停再恢复、最小化再还原 | 不崩溃、状态正确 | 状态机、资源管理 |
| 存档 | 关闭再打开 | 最高分保留 | 文件读写 |
演示脚本之外,答辩提问基本集中在五道题上,提前把答案写进设计文档的"关键设计说明"一节:为什么用双缓冲而不用 Direct2D;WM_TIMER 的精度问题怎么处理;碰撞为什么用圆而不是矩形;捕获概率的期望值算过没有;GDI 对象在哪里释放。第五题能当场把 WM_CREATE 和 WM_DESTROY 两个函数翻开对照着讲,基本就稳了。
还有一个我个人的习惯:把鱼种参数表打印一份带在身上。老师问"你这个概率是怎么定的",能直接指着一张表说"小黄鱼 35%、鲨鱼 1.5%,整局期望产出 340 金币、消耗 360 金币,所以玩家会略有亏损但不会挫败",这个回答的信息密度远高于"我随便调的"。数值设计能被讲清楚,是从"能跑"到"像个项目"的分水岭。
从那以后我每次交这类图形程序,都会在答辩前用一台没装过开发环境的机器裸跑一遍,把分辨率、DPI、路径、缺少的 DLL 全部踩一遍再上台。希望帮到你。
本文还有配套的精品资源,点击获取