Win32+GDI 捕鱼达人开发:消息循环、双缓冲与对象池实践
简介:面向高校C++课程设计与Windows游戏开发初学者的捕鱼达人项目源码包,以经典街机玩法为载体,帮助读者理解窗口创建、消息循环、图形绘制与交互响应的完整流程。压缩包共36个文件、约7.42MB,包含bmp位图与jpg背景、ogg/mp3/wav音效等美术音频素材,以及h头文件、cpp源文件、lib静态库和dsp/dsw工程配置,便于直接打开对照学习。已有1310人学习,适合需要课程设计参考或Windows API入门实践的学生。代码覆盖鱼群移动、子弹发射、碰撞检测、得分计算、资源加载释放与事件处理等环节,可据此梳理游戏主循环和模块划分,积累GDI绘图、资源管理及调试排错经验。
1. 从一份课设说起:捕鱼达人为什么适合拿来啃 Windows 程序设计
如果你的 C++ 大学课程设计还没定题,又不想交一个控制台版的学生成绩管理系统,这份 Windows 游戏方向的捕鱼达人小游戏基本是对胃口的:一个跑在桌面上的 C++ 程序,从窗口创建、消息循环到精灵动画、碰撞检测、金币结算全部自己写,不依赖任何游戏引擎。它值钱的地方不是玩法,而是把 Win32 那套东西完整串了一遍——画面闪烁、资源释放、定时器精度、鼠标点击偏移,这些在真实项目里同样会咬人的问题,在小工程里全都会冒出来。适合三类人:赶课设的学生、想从控制台跨到图形界面的新手、以及想拿个小工程练手 Win32 + GDI 的开发者。
2. 技术选型与工程骨架:Win32 API + GDI 怎么搭起来
2.1 为什么这类项目基本都落在 Win32 + GDI 上
先说结论:捕鱼达人这种 2D 小游戏,用 Win32 API 做窗口、GDI 做绘制,是成本最低、也最经得起答辩追问的方案。理由不复杂——课设的要求本身就是「调用 Windows API 写一个窗口程序」,你上引擎等于把题目绕过去了;Direct2D 虽然性能好,但要先做 COM 初始化、设备无关资源与设备相关资源两套对象管理,写进课设里属于杀鸡用牛刀;OpenGL 更是要额外配上下文和扩展加载。
GDI 的短板也要提前认:它没有硬件混合,逐像素 alpha 不支持,帧率上不去。所以常见做法是——主体用 GDI 的BitBlt/TransparentBlt,只在需要半透明(比如爆炸烟雾、鱼的淡出)时局部引一点 GDI+ 的AlphaBlend。这样既保持代码量可控,又不至于画面糙得像上世纪的东西。
分辨率上别贪心。客户区固定成 1024×640 这类常规尺寸,所有坐标按像素写死,省掉一套缩放矩阵;真要适配高分屏,就整体套一个缩放因子,别在每个绘制函数里各算各的。
2.2 目录结构:一个能编译的捕鱼达人该有哪些文件
很多人拿到这类源码包,第一反应是双击.sln然后 F5,编译不过就开始怀疑人生。其实先看清结构再动手,能省一小时。一个结构清晰的版本大致长这样:
| 文件 / 目录 | 职责 | 常见的坑 |
|---|---|---|
main.cpp | WinMain、窗口类注册、消息循环 | 链接器子系统设错会报找不到入口 |
GameApp.h/.cpp | 游戏状态机、分数、关卡推进 | 别把绘制代码混进来 |
Fish.h/.cpp | 鱼的结构体与对象池 | 池子容量太小会「漏鱼」 |
Bullet.h/.cpp | 炮弹发射与更新 | 每帧 new 一条必然卡 |
Cannon.h/.cpp | 炮台角度、倍率切换 | 角度要按客户区算 |
Renderer.h/.cpp | 所有 GDI 绘制集中在这里 | GDI 对象只在这层释放 |
GameConfig.h | 常量表:速度、概率、分值 | 数值散落各处就没法调 |
resource.h+.rc | 位图、图标、字符串资源 ID | 手改容易撞号 |
res/ | bmp、wav 资源文件 | 用相对路径,别写绝对路径 |
把绘制集中在Renderer这一层,是我强烈建议守住的边界。原因是 GDI 对象泄漏极难排查,一旦CreateCompatibleBitmap散落在五六个文件里,你最后只能对着任务管理器里的「GDI 对象」那一列干瞪眼。
2.3 窗口、消息循环与游戏主循环怎么合到一起
Win32 是消息驱动的,游戏是帧驱动的,这两套东西得焊在一起。入口先按标准流程创建窗口:
// main.cpp —— 入口与窗口创建,省略窗口类里填充图标、背景刷的细节 int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int nCmdShow) { WNDCLASSEX wc = { sizeof(WNDCLASSEX) }; wc.style = CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc = WndProc; // 窗口过程,处理 WM_LBUTTONDOWN 等 wc.hInstance = hInst; wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.lpszClassName = L"FishingJoyWnd"; RegisterClassEx(&wc); // 按客户区尺寸反推窗口尺寸,否则标题栏和边框会吃掉十几个像素 RECT rc = { 0, 0, kClientW, kClientH }; AdjustWindowRect(&rc, WS_OVERLAPPEDWINDOW & ~WS_THICKFRAME, FALSE); HWND hWnd = CreateWindowEx(0, L"FishingJoyWnd", L"捕鱼达人", WS_OVERLAPPEDWINDOW & ~WS_THICKFRAME, CW_USEDEFAULT, CW_USEDEFAULT, rc.right - rc.left, rc.bottom - rc.top, nullptr, nullptr, hInst, nullptr); ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd); return RunGameLoop(hWnd); // 主循环拆出去,见下 }这里有两处容易被忽略。AdjustWindowRect必须调,否则你按 1024×640 建窗口,实际客户区只有 1008×601,所有贴图位置和碰撞半径都会偏;CS_HREDRAW | CS_VREDRAW加上去是为了窗口尺寸变化时强制重绘,如果窗口固定大小,也可以去掉以减少无谓的WM_PAINT。
主循环不建议用WM_TIMER驱动,原因后面避坑那章细说。正常写法是下面这种固定步长循环:
// 固定步长主循环:逻辑按 60Hz 推进,渲染跟着消息到达走 int RunGameLoop(HWND hWnd) { LARGE_INTEGER freq, prev, now; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&prev); const double kStep = 1.0 / 60.0; // 逻辑步长 16.67ms double accumulator = 0.0; MSG msg = {}; for (;;) { // 有消息就处理,没有就继续跑帧,不能用 GetMessage 把线程卡住 while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { if (msg.message == WM_QUIT) return (int)msg.wParam; TranslateMessage(&msg); DispatchMessage(&msg); } QueryPerformanceCounter(&now); double frame = double(now.QuadPart - prev.QuadPart) / freq.QuadPart; prev = now; accumulator += frame; // 掉帧时最多补 5 步,防止卡顿后「追帧」把鱼瞬移到屏幕外 int steps = 0; while (accumulator >= kStep && steps++ < 5) { g_game.Update((float)kStep); accumulator -= kStep; } g_game.Render(hWnd); Sleep(1); // 让出时间片,否则风扇会叫 } }参数含义逐个说清:kStep是逻辑步长,决定了鱼一帧移动多少像素,写死成 1/60 意味着「每秒 60 次逻辑更新」;accumulator是时间累加器,把不等长的真实帧间隔攒够一个步长才推一次逻辑;steps < 5是保命阀,机器卡个半秒再回来时,不加上限会一次性补 30 步,鱼直接飞出去。Sleep(1)在游戏循环里不是偷懒,是防止空转把一个核吃满。
提示:
PeekMessage和GetMessage的区别是这行的分水岭。GetMessage在没消息时阻塞,游戏就停在原地等鼠标;PeekMessage取完就返回,你才能接着跑下一帧。
3. 核心模块拆解:鱼群、炮弹、碰撞与金币结算的完整链路
3.1 对象池:别每帧 new 一条鱼
新手最容易写的代码是std::vector<Fish*>加new Fish(),鱼出屏幕再delete。跑十分钟不觉得有问题,跑半小时就开始一顿一顿——堆上碎片多了,分配器撑不住。这类游戏的对象生命周期高度规律(生成 → 存活 → 出界),天生适合对象池。
// Fish.h —— 固定容量的鱼池,全生命周期不扩容 constexpr int kMaxFish = 64; struct Fish { bool alive = false; // 空闲标记,池子的核心 int type = 0; // 鱼种下标:决定贴图、半径、分值 float x = 0, y = 0; // 屏幕坐标,中心点对齐 float vx = 0, vy = 0; // 速度,单位像素/秒 float radius = 0; // 碰撞半径,不能拿贴图宽度顶替 int hp = 1; float animTime = 0; // 帧动画累计时间 }; class FishPool { public: Fish* Acquire() { // 取一条空闲鱼,池满返回空指针 for (auto& f : fish_) { if (!f.alive) { f.alive = true; return &f; } } return nullptr; } void Update(float dt) { for (auto& f : fish_) { if (!f.alive) continue; f.x += f.vx * dt; f.y += f.vy * dt; f.animTime += dt; // 回收要算上鱼身宽度,否则长条鱼游到一半就「消失」 if (f.x < -200.f || f.x > kClientW + 200.f) f.alive = false; } } Fish* Data() { return fish_; } int Size() const { return kMaxFish; } private: Fish fish_[kMaxFish]; };几个参数值得展开。radius单独存,是因为鲨鱼的贴图可能 200 像素宽,但它的碰撞应该只覆盖躯干部分,用贴图宽度做碰撞会让玩家在鱼尾碰到时就被判定捕获,手感非常差。回收边界加200的余量,是因为鱼从右侧进场时中心点还在屏幕外,过早回收会造成「半条鱼凭空消失」的观感。池满时直接返回空指针、放弃生成这一条,而不是扩容——这是有意为之,池子的价值就在于容量可预测。
3.2 鱼群生成:权重表比 if-else 好维护
刷鱼逻辑本身不难,难的是「关卡越高鱼越密、大鱼越少」这种节奏控制。硬编码一堆if (level > 3)最后没人敢动,正确做法是把鱼种权重做成表。
// 按关卡查权重,权重越大概率越高;第 0 关只放小鱼,第 3 关才出鲨鱼 static const int kWeight[4][4] = { { 70, 30, 0, 0 }, // level 0 { 55, 35, 10, 0 }, // level 1 { 40, 35, 20, 5 }, // level 2 { 30, 30, 25, 15 }, // level 3+ }; int Game::PickFishType(int level) { const int* w = kWeight[min(level, 3)]; int total = w[0] + w[1] + w[2] + w[3]; int roll = rand() % total; for (int i = 0; i < 4; ++i) { if (roll < w[i]) return i; roll -= w[i]; } return 0; } void Game::SpawnFish(float dt) { spawnTimer_ -= dt; if (spawnTimer_ > 0.f) return; // 关卡越高间隔越短,但夹一个 0.25s 下限,否则后期鱼挤成一坨 spawnTimer_ = max(0.8f - level_ * 0.05f, 0.25f); int count = 1 + rand() % 3; for (int i = 0; i < count; ++i) { Fish* f = pool_.Acquire(); if (!f) return; f->type = PickFishType(level_); f->radius = kFishRadius[f->type]; f->hp = kFishHp[f->type]; f->animTime = 0.f; bool fromLeft = (rand() % 2 == 0); f->x = fromLeft ? -60.f : kClientW + 60.f; f->y = 60.f + rand() % (kClientH - 200); // 避开顶部 HUD 和底部炮台 float spd = 60.f + rand() % 90 + level_ * 10.f; f->vx = fromLeft ? spd : -spd; f->vy = float(rand() % 40 - 20); // 纵向漂移,避免鱼排成直线 } }kFishRadius/kFishHp这类数组统一放在GameConfig.h,改数值不用翻实现文件。f->y的取值范围要上下留白,上面留给金币和关卡显示,下面留给炮台,否则鱼会游到 UI 后面被挡。纵向速度用rand() % 40 - 20给一个 ±20 的抖动,视觉上鱼群就不会像军训方阵。
3.3 炮弹与碰撞:一次遍历解决两件事
炮弹的更新和碰撞检测可以合成一趟循环,没必要先全部移动再全部检测,多出来的那层遍历在小游戏里纯属浪费。
void Game::UpdateBullets(float dt) { for (auto& b : bullets_) { if (!b.alive) continue; b.x += b.vx * dt; b.y += b.vy * dt; if (b.y < -32.f || b.y > kClientH + 32.f) { b.alive = false; continue; } for (int i = 0; i < pool_.Size(); ++i) { Fish* f = &pool_.Data()[i]; if (!f->alive) continue; // 圆与圆相交,用半径平方比较,省掉每帧几百次 sqrt float dx = f->x - b.x, dy = f->y - b.y; float rr = f->radius + b.radius; if (dx * dx + dy * dy > rr * rr) continue; b.alive = false; if (--f->hp <= 0) OnFishCaught(*f, b); break; // 一颗炮弹只碰一条鱼 } } }dx * dx + dy * dy > rr * rr这个写法是必须的:开方是浮点里比较贵的操作,一百条鱼乘二十发炮弹就是两千次,改成平方比较后一次都不需要。break保证一颗炮弹打到鱼就消失,不会出现一发子弹连穿三条鱼的穿模。--f->hp <= 0把血量扣减和判定写在一起,是为了给河豚这类需要两发才死的鱼留出扩展位。
炮台角度通过WM_LBUTTONDOWN里的客户区坐标算,用atan2求出方向,再乘上炮弹速度就得到vx / vy。这里有个细节:鼠标点得离炮台太近时atan2出来的角度会跳,常见做法是距离小于 40 像素就直接用上一次的角度,或者禁用发射。
3.4 捕获概率与金币结算:数值不通胀才玩得下去
如果打中就死,这游戏三分钟就没人玩了。捕鱼达人类玩法的核心是「概率 + 倍率 + 期望收益」。
bool Game::TryCatch(const Fish& f, const Bullet& b) { // 鱼种 0~3 对应 小鱼 → 鲨鱼,基础捕获率递减 static const float kBaseRate[4] = { 0.90f, 0.55f, 0.30f, 0.08f }; float rate = kBaseRate[f.type] * b.power; // power 是炮台倍率 1/2/5 rate = min(rate, 0.95f); // 封顶,鲨鱼不能变成必中 return (rand() / float(RAND_MAX)) < rate; } void Game::OnFishCaught(const Fish& f, const Bullet& b) { if (!TryCatch(f, b)) { coin_ -= b.cost; // 打不中也要扣炮弹钱,这是玩法底层 return; } int gain = kFishValue[f.type] * b.power; coin_ += gain - b.cost; // 净收益 caughtCount_[f.type]++; if (coin_ <= 0) state_ = GameState::Over; }要守住一条规则:每种鱼的「捕获率 × 分值」必须小于炮弹成本,否则金币总量会单向通胀,玩家永远输不了,关卡设计就失效了。举个能过关的配置——小鱼捕获率 0.9、分值 2,单发成本 1,期望收益 1.8,微赚;鲨鱼捕获率 0.08、分值 50,期望收益 4,但一发成本 5,长期是亏的。这样玩家会自然地在小鱼身上攒钱、在大鱼身上赌一把,节奏就出来了。改数值的时候建议先写张纸算一遍期望,比反复试玩快得多。
4. 渲染与资源管理:双缓冲、透明位图与帧动画
4.1 双缓冲:闪烁问题只有这一种根治办法
不夸张地说,捕鱼达人这类项目里,画面闪烁是最常见的翻车点。原因是你在窗口 DC 上先刷背景、再画鱼、再画炮弹,屏幕是逐步刷新的,中间态被用户看到了。唯一靠谱的解法是双缓冲——所有绘制先在内存 DC 上画完,最后一次性贴回屏幕。
void Game::Render(HWND hWnd) { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 双缓冲三件套:内存 DC + 兼容位图 + 选入 HDC memDC = CreateCompatibleDC(hdc); HBITMAP memBmp = CreateCompatibleBitmap(hdc, kClientW, kClientH); HBITMAP oldBmp = (HBITMAP)SelectObject(memDC, memBmp); DrawBackground(memDC); // 背景先铺满,别留黑边 Renderer::DrawFish(memDC, pool_); Renderer::DrawBullets(memDC, bullets_); Renderer::DrawCannon(memDC, cannon_); Renderer::DrawHUD(memDC, coin_, level_); BitBlt(hdc, 0, 0, kClientW, kClientH, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); // 必须还原,否则旧位图泄漏 DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hWnd, &ps); }SelectObject还原那一步是保命的:创建的时候把memBmp选进memDC,删除之前必须先选回oldBmp,否则这张位图还在 DC 里被引用,DeleteObject会失败,跑十几分钟 GDI 对象数就爆了。另外这套代码每帧都在创建销毁位图,功能没问题但效率一般——实际项目里我会把memDC和memBmp提到Renderer里做成成员,只在窗口尺寸变化时重建,帧内只做BitBlt。
背景值得单独说一句:如果每帧都用一张大位图铺底,BitBlt的开销不小;常见做法是背景只画一次到一张离屏位图,之后每帧BitBlt这张缓存,鱼和炮弹画在上面。水波纹这类动效可以叠一层,但别三层以上。
4.2 TransparentBlt:把位图的方形底色抠掉
直接从文件加载的 bmp 是矩形的,贴到屏幕上会带着一整块洋红色或白色的底,画面立刻变得不能看。TransparentBlt干的就是指定一个颜色当透明色抠掉。
// 需要链接 msimg32.lib,否则会报 LNK2019 #pragma comment(lib, "msimg32.lib") void Renderer::BlitSprite(HDC dc, HBITMAP bmp, int cx, int cy, int w, int h, COLORREF trans) { HDC tmp = CreateCompatibleDC(dc); HBITMAP old = (HBITMAP)SelectObject(tmp, bmp); // 前四个参数是目标矩形,后四个是源矩形,最后一个参数是透明色 TransparentBlt(dc, cx - w / 2, cy - h / 2, w, h, tmp, 0, 0, w, h, trans); SelectObject(tmp, old); DeleteDC(tmp); }trans一般固定成RGB(255, 0, 255)(洋红),因为美术素材里几乎不会出现这种纯色;如果你拿到的是白底图,也可以用RGB(255,255,255),但要小心,鱼身上的高光一旦是纯白就会被打成洞。
注意:
TransparentBlt只认纯色透明,不认 PNG 的 alpha 通道。素材如果是带半透明边缘的 PNG,得换AlphaBlend或者 GDI+ 的Image::Draw,前者的实现要多写二十行,课设里不划算,建议直接让素材出成 bmp。
另外,TransparentBlt每次调用都要建一个临时 DC,如果一帧里画 60 条鱼就是 60 次创建销毁,属实浪费。优化方式很简单:把鱼的位图一次性SelectObject进一个常驻内存 DC,绘制时只切位图不切 DC。
4.3 帧动画:按时间切精灵表,别按帧计数
鱼尾巴摆动的动画一般做成一张长条精灵表,横向切成 N 帧。切片本身不难,难在时间控制——按「每 5 帧换一张图」写,遇到掉帧动画就变速,玩家一眼就看出来。
// 精灵表:横向排列,每帧 64x64,共 frames 帧 void Renderer::DrawFishSprite(HDC dc, HBITMAP sheet, int frames, float animTime, int dx, int dy) { const int kFrameW = 64, kFrameH = 64; const float kFps = 12.f; // 每秒 12 帧,鱼摆动比较自然 int frame = int(animTime * kFps) % frames; // 源矩形按帧号横向偏移,注意别越界到下一行 TransparentBlt(dc, dx - kFrameW / 2, dy - kFrameH / 2, kFrameW, kFrameH, sheet, frame * kFrameW, 0, kFrameW, kFrameH, RGB(255, 0, 255)); }animTime是每条鱼自己累计的秒数,从 3.2 节的f.animTime += dt传进来。用时间而不是帧序号,好处是逻辑不受帧率影响:哪怕机器卡到 20 帧,动画速度依然是 12 帧每秒,只是看起来略跳。% frames保证循环回第一帧,但如果图集不是从第 0 帧开始排列,这个取模就得改成(frame + startOffset) % frames。
资源释放是这一章最后一条边界:所有LoadImage出来的HBITMAP,在WM_DESTROY里必须逐个DeleteObject。我见过太多版本是把贴图加载进一个全局数组然后就当它不存在,短时间跑不出问题,长时间跑内存曲线一路向上。
5. 避坑与排查:这类小游戏最容易翻车的六个地方
5.1 编译链接期:入口点、库依赖和字符集
现象:编译无错,链接报LNK2019:无法解析的外部符号 WinMain(或者反过来报main)。原因:项目属性里的子系统还是「控制台」,链接器在找main,而你的代码写的是WinMain。解决:项目属性 → 链接器 → 系统 → 子系统改成「Windows」;懒得点属性就加一行#pragma comment(linker, "/SUBSYSTEM:WINDOWS")。顺带确认入口函数签名是int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int),四个参数一个都不能少。
现象:代码里明明写了TransparentBlt,链接阶段报找不到符号。原因:这个函数不在gdi32.lib里,属于msimg32.lib,默认不链。解决:源文件顶部加#pragma comment(lib, "msimg32.lib"),或者在附加依赖项里补上。
现象:窗口标题、MessageBox里的中文全是乱码,或者编译直接报L"..."相关错误。原因:字符集设置和字符串字面量不匹配。项目开了 Unicode 字符集,代码里却写"捕鱼达人"这种窄字符;或者反过来,代码全是L"",项目却是多字节。解决:统一走 Unicode,所有字符串字面量加L前缀,API 调用用...W版本;或者项目属性里把字符集改成「使用多字节字符集」。两种都行,混着用必翻车。
5.2 运行期:闪烁、卡顿与句柄泄漏
现象:鱼游动时画面明显抖动,背景一刷整屏闪白。原因:直接在窗口 DC 上逐层绘制,用户看到了绘制中间态。解决:按 4.1 节上双缓冲,所有绘制进内存 DC,最后一次BitBlt贴回。
现象:程序跑十几分钟后画面变全黑,CreateCompatibleBitmap返回 NULL,任务管理器里 GDI 对象数几千。原因:创建的HBITMAP/HDC/HPEN没释放,或者SelectObject之后没还原就DeleteObject,删除失败。解决:所有 GDI 对象成对释放,顺序固定为「选回旧对象 → 删新对象 → 删 DC」;把位图缓存成成员,别每帧创建。排查时打开任务管理器加一列「GDI 对象」,正常应该稳定在几十。
现象:鼠标点下去没反应,或者明明点中了鱼,炮弹却朝另一个方向飞。原因:用了GetCursorPos拿到的是屏幕坐标,没做ScreenToClient;或者处理WM_LBUTTONDOWN时直接用了LOWORD(lParam)却忘了窗口有边框偏移。解决:WM_LBUTTONDOWN的lParam里就是客户区坐标,用GET_X_LPARAM/GET_Y_LPARAM取;非要用GetCursorPos就补一句ScreenToClient(hWnd, &pt)。
现象:在自己电脑上鱼游得正好,换台机器要么快进要么慢动作。原因:用WM_TIMER驱动逻辑更新,而WM_TIMER的精度只有 15.6ms 左右,消息还会积压;或者逻辑更新按帧计数,帧率一变速度就变。解决:改成PeekMessage+QueryPerformanceCounter的固定步长循环,所有移动量都乘dt。这条是血泪经验,改成时间驱动之后同类问题基本绝迹。
现象:切出去看个文档再切回来,鱼全部堆在屏幕边上。原因:窗口失焦期间主循环仍在跑,但accumulator累积了大量时间,回来时一次性补了几十步。解决:补步数加上限(如 5 步),并在WM_ACTIVATE里把accumulator清零、prev重置为当前时间。
6. 二次开发:把捕鱼达人改成你自己的玩法
拿到这份代码,最容易见效的改造是调数值,不是改结构。第一步永远是把GameConfig.h里那张常量表过一遍——鱼的速度区间、刷怪间隔、捕获率数组、炮弹成本与倍率,这四组数决定了整局体验。我一般会先做个最小验证:把鲨鱼捕获率从 0.08 改到 0.08 并保持成本不变,跑十局统计金币曲线,看是不是稳定向下;如果曲线单边上涨,说明期望算出问题了,得回头调分值。
加新鱼种是最常见的需求,路径固定为三步:res/里补一张精灵表、kFishRadius/kFishValue/kBaseRate三个数组各加一个元素、kWeight权重表里给对应关卡分配比例。三处都改完才能跑,只改两处会出现「鱼画出来了但打中不结算」或者「结算了但用的是下标 0 的分值」这类玄学问题。
| 改造点 | 改哪个文件 | 验证方式 |
|---|---|---|
| 加鱼种 / 调概率 | GameConfig.h的四个数组 | 跑 10 局统计金币净变化 |
| 换美术风格 | res/+Renderer的透明色常量 | 看是否有白边或空洞 |
| 加新炮台倍率 | Cannon的倍率枚举 +Bullet::cost | 检查期望收益是否仍为负 |
| 换成 GDI+ 渲染 | Renderer整个替换 | 帧率、内存、GDI 对象数三项对比 |
再往深一层就是换渲染层。因为绘制全集中在Renderer里,把TransparentBlt换成 GDI+ 的Graphics::DrawImage只需要动这一层,逻辑代码一行不改。换完之后 PNG 的半透明边缘就能正确显示,代价是每帧的绘制耗时大概翻一倍,得配合缓存位图才划得来。
最后提一个验证习惯:改完玩法别只靠肉眼试玩,加一个隐藏的调试开关,把「当前帧率、存活鱼数、GDI 对象数、金币」四个数打在角落。我吃过亏——有个版本鱼越打越多、帧率一路掉,肉眼只觉得「有点卡」,把存活鱼数打出来才发现池子的alive标记在某条分支上没被正确复位。从那以后我每次动对象池相关代码,都强制把这三个计数跑一遍再提交。希望帮到你。
本文还有配套的精品资源,点击获取