简介:面向高校C++课程设计与Windows游戏开发入门者的“捕鱼达人”实践项目,围绕窗口创建、GDI绘图、消息循环、鼠标事件、定时器与多线程等知识点,给出可运行的游戏逻辑示例,适合用来理解面向对象封装、继承与多态在小型游戏中的落地方式。压缩包共36个文件,约7.42MB,以bmp图像、ogg/mp3/wav音效、txt参数说明、h头文件与cpp源码为主,并附带lib库及Visual Studio工程文件,便于直接打开调试和替换素材。已有1311人学习,项目覆盖鱼群移动、子弹发射、碰撞检测、得分计算、资源加载与内存释放等环节,读者可据此梳理Windows游戏主循环和事件处理流程,也可作为课程设计选题、代码拆解与二次开发的参考底稿。
1. 一个能交作业也能二次开发的捕鱼小游戏
课程设计选题里,C++ 加 Windows 程序设计这套组合最容易出问题的地方往往不是语法,而是“窗口能弹出来,画面却是死的”。我见过太多捕鱼达人小游戏交上去只有一张静态背景图,鱼不动、炮不转、点了没反应,答辩时演示三秒钟就结束了。这份资源面向的就是这种情况:一个完整的 Win32 桌面小游戏工程,鱼群沿路径游动、炮台跟随鼠标旋转、炮弹按角度飞出并做圆形碰撞判定、命中后结算金币,整套帧循环跑在标准消息泵里。它适合两类人,一是要交大学课程设计、需要一份能编译能演示的完整工程;二是从控制台转 Windows 图形编程,需要一个能看懂、敢下手改的样本。工程不依赖大型引擎,编译器和系统自带的库就够,拿到手先跑通再谈改造。
2. 技术选型与工程骨架:为什么 Win32 + GDI 双缓冲比想象中耐用
2.1 三种常见做法,以及它们各自的代价
Windows 上写 2D 小游戏,课程设计层面基本绕不开三条路。第一条是纯 Win32 API 配 GDI 绘制,代码量偏大,但每一个像素怎么来的你都清楚;第二条是 MFC 套一层文档视图,向导点几下窗口就出来了,代价是消息被层层封装,出了画面问题很难定位到底哪一层在捣乱;第三条是引第三方图形库,比如 EasyX 这类对 GDI 做了轻量封装的库,画圆画图一句话搞定,上手最快。
这份工程走的是第一条路。原因很实际:课程设计通常要求“体现 Windows 程序设计能力”,纯 Win32 的窗口类注册、消息循环、GDI 对象管理、定时器这些点,恰好是考察重点;同时它没有第三方依赖,拷到任何一台装了编译器的机器上都能编。MFC 的问题在于,很多同学最后交上去的工程自己都说不清OnPaint和OnDraw的调用关系,老师一问就容易卡住。
GDI 本身是老了,但它对小游戏完全够用。真正让人栽跟头的不是 GDI 慢,而是用错了方式——直接在窗口的屏幕 DC 上一条一条画,每画一条系统就刷一次屏,画面就会闪得像接触不良。解决办法是老生常谈的双缓冲:所有绘制先落到内存 DC,整帧画完再一次性BitBlt贴到屏幕。这套做法放到今天依然成立,帧率稳定在 60 附近没有压力。
2.2 工程骨架:窗口类、消息泵与定时器三件套
不管后面加多少条鱼,程序的骨架永远是那三段。第一段注册窗口类,告诉系统“我这个窗口长什么样、消息交给谁处理”;第二段创建窗口并进入消息循环;第三段在窗口过程里分发具体消息。游戏逻辑不能自己写个while(true)死循环,那样消息泵会被堵死,窗口直接变成“未响应”,这是新手最容易踩的坑。
// main.cpp —— 程序入口与消息泵 int APIENTRY wWinMain(HINSTANCE hInst, HINSTANCE, LPWSTR, int nCmdShow) { // 1. 注册窗口类,lpfnWndProc 指定所有消息的落点 WNDCLASSEXW wc = { sizeof(wc) }; wc.lpfnWndProc = WndProc; wc.hInstance = hInst; wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.hbrBackground = (HBRUSH)GetStockObject(BLACK_BRUSH); wc.lpszClassName = L"FishGameWnd"; if (!RegisterClassExW(&wc)) return -1; // 2. 固定客户区大小,禁止拉伸,避免坐标体系被破坏 DWORD style = WS_OVERLAPPEDWINDOW & ~WS_THICKFRAME & ~WS_MAXIMIZEBOX; RECT rc = { 0, 0, kClientW, kClientH }; AdjustWindowRect(&rc, style, FALSE); // 把客户区尺寸换算成窗口尺寸 HWND hWnd = CreateWindowExW(0, wc.lpszClassName, L"捕鱼达人", style, CW_USEDEFAULT, CW_USEDEFAULT, rc.right - rc.left, rc.bottom - rc.top, nullptr, nullptr, hInst, nullptr); if (!hWnd) return -1; ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd); // 3. 标准消息循环,收到 WM_QUIT 时 GetMessage 返回 0 退出 MSG msg; while (GetMessageW(&msg, nullptr, 0, 0)) { TranslateMessage(&msg); DispatchMessageW(&msg); // 转发给 WndProc } return (int)msg.wParam; }这里有几个参数值得单独拎出来说。kClientW/kClientH是客户区尺寸,也就是你真正能画图的区域,不含标题栏和边框;AdjustWindowRect负责把它换算成创建窗口时该传的宽高,很多人忘了这一步,结果游戏画面被边框裁掉一圈,鼠标坐标和鱼的位置永远差十几个像素。样式里去掉WS_THICKFRAME和WS_MAXIMIZEBOX是为了锁死分辨率——小游戏里所有坐标、贴图、碰撞半径都按 1024×600 写死,一旦允许拉伸,要么画面留黑边,要么全部错位,没有后悔药。
GetMessageW是阻塞式的,没有消息时线程挂起,不占 CPU;DispatchMessageW把消息送回WndProc。游戏的“动”靠的不是循环,而是在WndProc里接WM_TIMER。这套骨架的好处是窗口拖动、最小化、关闭这些系统行为全部由系统托管,你只需要关心自己的逻辑。
帧推进用SetTimer发起,间隔写 16 毫秒,对应理论 60 帧。但要清楚一点:SetTimer的精度受系统时钟片影响,实际间隔可能是 15.6 或 31 毫秒,还会因为窗口被拖动而积压。所以逻辑更新绝不能假设“每次定时器就是 1/60 秒”,必须用高精度计数器算真实间隔,这一点在第 4 章的帧循环里展开。
3. 目录结构与模块拆解:从 WinMain 到一帧画面的完整链路
3.1 目录清单与每个文件的职责边界
工程按“入口 / 应用层 / 实体层 / 渲染层 / 资源层”切分,每个.cpp只做一件事。这样切开的好处是二次开发时改动面可控——加一种鱼只需要动实体层和鱼种表,加一个特效只动渲染层,不会牵一发动全身。
| 文件 | 职责 | 改动频率 |
|---|---|---|
main.cpp | 入口、窗口类注册、消息泵 | 极低 |
GameApp.h/.cpp | 窗口过程、状态机、帧调度、碰撞调度 | 中 |
Fish.h/.cpp | 鱼实体、鱼群生成器、路径更新 | 高 |
Bullet.h/.cpp | 炮弹实体、发射与生命周期 | 中 |
Renderer.h/.cpp | 双缓冲、贴图、文字绘制 | 低 |
Resource.h/.cpp | 位图与资源的加载释放 | 低 |
res/game.rc | 位图资源索引 | 加图时改 |
res/*.bmp | 鱼、炮台、炮弹、背景 | 加图时改 |
分层的判断标准很简单:Fish.cpp里不允许出现HDC,Renderer.cpp里不允许出现鱼的速度和血量。实体层只管数值,渲染层只管把数值变成像素。这条线守住,代码就不会烂。
Resource层有个容易忽略的细节:资源释放。GDI 对象是内核对象,CreateCompatibleBitmap创建的位图、CreateSolidBrush创建的画刷,不DeleteObject就会泄漏,跑一局两局看不出来,挂机十分钟后系统句柄数飙上去,画面就开始出问题。这属于典型的“不报错但会崩”的问题。
3.2 消息处理链路:六条消息撑起整个游戏
窗口过程是整个程序的中枢,真正需要自己处理的消息其实只有六条:
LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: g_app.OnCreate(hWnd); // 初始化渲染器、加载资源、启动定时器 return 0; case WM_TIMER: if (wParam == TIMER_FRAME) g_app.OnFrame(); return 0; case WM_MOUSEMOVE: g_app.OnMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; case WM_LBUTTONDOWN: g_app.OnFire(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); return 0; case WM_PAINT: g_app.OnPaint(hWnd); // 只负责把内存位图贴到屏幕 return 0; case WM_DESTROY: g_app.OnDestroy(); // 停定时器、释放 GDI 对象 PostQuitMessage(0); return 0; } return DefWindowProc(hWnd, msg, wParam, lParam); }WM_CREATE是所有初始化的入口,注意它发生在窗口还没显示的时候,此时GetDC拿到的 DC 尺寸可能不准,稳妥做法是直接用常量kClientW/kClientH建内存位图。WM_MOUSEMOVE和WM_LBUTTONDOWN里的坐标是相对于客户区的,和你的绘制坐标系天然对齐,不需要再做换算。WM_PAINT里千万不能写游戏逻辑,它会被系统在窗口遮挡、移动、最小化恢复时随机触发,逻辑写进去就会出现“切一下窗口鱼就往前跳一大段”的奇怪现象。
WM_DESTROY里的清理顺序也有讲究:先KillTimer,再释放渲染器(SelectObject还原旧位图、DeleteObject新位图、DeleteDC),最后PostQuitMessage。顺序反了,定时器可能在资源已经释放之后又触发一次,直接访问野指针。
3.3 游戏对象的数据结构:结构体数组而非继承树
实体层用的是最朴素的结构体数组,鱼和炮弹各自一个std::vector,靠alive标记做对象池,不erase。这个选择在课程设计规模下是最优的:鱼的数量上限也就三四十条,炮弹同时存在不超过十条,遍历全量做碰撞检测的开销可以忽略,而erase会带来迭代器失效和内存搬移,反而容易出问题。
// Fish.h —— 鱼实体,字段全部为逻辑值,不含任何 GDI 句柄 struct Fish { float x, y; // 中心点坐标,单位像素 float vx, vy; // 速度,单位像素/秒,与 dt 相乘推进 int type; // 鱼种索引,指向 kFishTypes 表 int hp; // 剩余血量 int score; // 击杀得分 float radius; // 碰撞半径 float animTime; // 动画累计时间,用于算当前帧 bool alive; }; struct Bullet { float x, y; float vx, vy; int power; // 伤害值 float radius; bool alive; };坐标用float而不是int是硬性要求。如果用整数存位置,每帧累加v*dt,当速度是 90 像素/秒、dt 是 0.016 秒时,每帧位移只有 1.44,取整后变成 1,鱼实际速度就只剩 62 像素/秒,肉眼能看出明显的迟滞和抖动。速度单位统一成“像素/秒”而不是“像素/帧”,也是为了让逻辑和帧率解耦,帧率掉到 30 时鱼该走多快还是多快。
4. 核心实现逐段拆解:鱼群、炮弹、碰撞与计分
4.1 帧循环:把定时器抖动吃掉的 delta time
WM_TIMER只是“该更新了”的信号,真正的时间基准要自己算。用QueryPerformanceCounter取高精度计数,两次相减除以频率就是真实间隔。
// GameApp.cpp —— 帧调度 void GameApp::OnCreate(HWND hWnd) { LARGE_INTEGER f; QueryPerformanceFrequency(&f); m_qpcFreq = (double)f.QuadPart; QueryPerformanceCounter(&m_lastTick); m_renderer.Init(hWnd, kClientW, kClientH); m_res.LoadAll(); m_fish.SpawnInitial(12); // 开局先铺 12 条鱼,避免空场 SetTimer(hWnd, TIMER_FRAME, 16, nullptr); } void GameApp::OnFrame() { LARGE_INTEGER now; QueryPerformanceCounter(&now); double dt = (now.QuadPart - m_lastTick.QuadPart) / m_qpcFreq; m_lastTick = now; // 拖动窗口或断点调试后 dt 会瞬间变大,必须钳位 if (dt > 0.05) dt = 0.05; if (m_state == GameState::Playing) { m_fish.Update(dt); m_bullets.Update(dt); CheckCollisions(); m_fish.TrySpawn(dt); // 按时间间隔补鱼 } Render(); m_renderer.Present(m_hWnd); }dt钳位的上限我给 0.05 秒,也就是单帧最多推进 50 毫秒。不加这一行,一旦窗口被鼠标按住拖动,系统会把积压的WM_TIMER一次性补发,dt变成两三秒,鱼瞬间从屏幕左边飞到右边,炮弹直接穿过所有目标。这个现象第一次遇到会非常困惑,因为调试器里看逻辑完全正确,只有实际拖动窗口才复现。
逻辑更新和渲染分开写是另一条经验。Update只改数值,Render只读数值,两者不交叉。这样即使某一帧渲染卡了,逻辑推进依然按真实时间走,不会出现“画面卡一下鱼就慢一下”的连锁反应。
4.2 双缓冲渲染:一帧只贴一次屏
渲染器把内存 DC 封装起来,外面只暴露Clear、DrawSprite、DrawText、Present四个方法。所有绘制都发生在内存位图上,屏幕只在最后一刻被写一次。
// Renderer.cpp —— 内存 DC + 兼容位图 bool Renderer::Init(HWND hWnd, int w, int h) { m_hdcScreen = GetDC(hWnd); m_hdcMem = CreateCompatibleDC(m_hdcScreen); m_hBmp = CreateCompatibleBitmap(m_hdcScreen, w, h); if (!m_hBmp) return false; m_hOldBmp = (HBITMAP)SelectObject(m_hdcMem, m_hBmp); SetBkMode(m_hdcMem, TRANSPARENT); // 文字背景透明,否则会有黑框 m_w = w; m_h = h; return true; } void Renderer::Present(HWND hWnd) { HDC hdc = GetDC(hWnd); BitBlt(hdc, 0, 0, m_w, m_h, m_hdcMem, 0, 0, SRCCOPY); ReleaseDC(hWnd, hdc); // 成对释放,别用 m_hdcScreen 直接贴 } void Renderer::Shutdown() { SelectObject(m_hdcMem, m_hOldBmp); // 必须先还原,否则位图删不掉 DeleteObject(m_hBmp); DeleteDC(m_hdcMem); ReleaseDC(m_hWnd, m_hdcScreen); }SetBkMode(TRANSPARENT)这行看着不起眼,但少写就会在金币数字下面看到一块黑色底,因为 GDI 默认用背景色填充文字包围盒。Present里每次都GetDC/ReleaseDC而不是长期持有m_hdcScreen,是因为窗口 DC 属于稀缺资源,长期占用在别的程序抢占时会出问题。
贴图部分用TransparentBlt处理带透明色的位图(比如鱼的外轮廓),纯色背景直接BitBlt。这两种函数的参数顺序都是“目标 DC、目标坐标、宽高、源 DC、源坐标、宽高、光栅操作码”,多写两次就熟了。
4.3 鱼群生成与路径运动:出屏回收 + 权重随机
鱼不是凭空消失再凭空出现的,而是游出屏幕一侧后从另一侧重新进场,这样场景里始终维持固定数量,不会出现一会儿满屏一会儿空场。
// Fish.cpp —— 更新与回收 void FishManager::Update(float dt) { for (auto& f : m_fishes) { if (!f.alive) continue; f.x += f.vx * dt; f.y += f.vy * dt; f.animTime += dt; const FishType& t = kFishTypes[f.type]; // 出右边界:从左侧重新进场,纵向随机换一条泳道 if (f.vx > 0 && f.x - t.radius > m_width) { f.x = -t.radius; f.y = RandRange(60.f, m_height - 60.f); } else if (f.vx < 0 && f.x + t.radius < 0) { f.x = m_width + t.radius; f.y = RandRange(60.f, m_height - 60.f); } } } // 按权重抽鱼种:小鱼多、大鱼少,否则金币通胀 int FishManager::PickType() { static const int kWeight[] = { 50, 30, 15, 5 }; // 与 kFishTypes 一一对应 int total = 0; for (int w : kWeight) total += w; int r = rand() % total; for (int i = 0; i < 4; ++i) { if (r < kWeight[i]) return i; r -= kWeight[i]; } return 0; }RandRange(60.f, m_height - 60.f)里留出 60 像素边距,是为了让鱼的身子完整出现在画面内,不然半条鱼贴着边缘游过去,视觉上很别扭。权重数组是这套生成器的核心,小鱼权重 50、金鲨权重 5,玩家的收益曲线才合理;如果四种鱼等概率出现,一局下来金币涨得飞快,炮台升级瞬间失去意义,游戏性就没了。
animTime累积到一定值就切换下一帧,帧数由kFishTypes[i].frames决定。用时间累加而不是“每帧切一张”,是为了让动画速度不受帧率影响。
4.4 炮弹发射与圆形碰撞:一次遍历解决判定
炮台角度由鼠标位置反算,炮弹沿该角度直线飞行。碰撞判定用圆形近似,比较距离的平方,省掉每帧几十次开方。
// GameApp.cpp —— 发射 void GameApp::OnFire(int mx, int my) { if (m_state != GameState::Playing) return; if (m_coins < m_cannonCost) return; // 金币不足不能开炮 Bullet b{}; b.x = kCannonX; b.y = kCannonY; float dx = (float)mx - b.x; float dy = (float)my - b.y; float len = sqrtf(dx * dx + dy * dy); if (len < 1.0f) return; // 鼠标压在炮口上,方向无意义 b.vx = dx / len * kBulletSpeed; b.vy = dy / len * kBulletSpeed; b.power = m_cannonPower; b.radius = 8.0f; b.alive = true; m_coins -= m_cannonCost; m_bullets.Push(b); } // 圆形碰撞:距离平方比较 static inline bool HitCircle(float ax, float ay, float ar, float bx, float by, float br) { float dx = ax - bx, dy = ay - by, r = ar + br; return dx * dx + dy * dy <= r * r; } void GameApp::CheckCollisions() { for (auto& b : m_bullets.Items()) { if (!b.alive) continue; for (auto& f : m_fish.Items()) { if (!f.alive) continue; if (!HitCircle(b.x, b.y, b.radius, f.x, f.y, f.radius)) continue; b.alive = false; // 一颗炮弹只结算一次 f.hp -= b.power; if (f.hp <= 0) { f.alive = false; m_score += f.score; m_coins += f.score; // 命中返还金币,形成正反馈 } break; // 关键:跳出内层,防止一炮多鱼 } } }break那一行是踩过坑才加的。最早版本没有它,一颗炮弹飞过大鱼身边时会在同一帧命中同一条鱼的多个判定点,血量直接扣两三次,玩家感觉“这鱼怎么一碰就碎”。加了break之后,一颗炮弹的命中结果唯一,后面再想做成穿透弹,就把break换成“记一个已命中列表”,改动面很小。
伤害与得分的关系也值得说一句:小鱼hp=1、score=2,金鲨hp=8、score=50。血厚分高,但每点血对应的收益反而更高,这样玩家才有动力去追大鱼,而不是无脑打小鱼刷数量。
4.5 计分与状态机:三行代码隔开菜单和游戏
状态机用一个枚举加一层switch就够了,不需要引入任何模式。
enum class GameState { Menu, Playing, Paused, Over }; void GameApp::Render() { m_renderer.Clear(RGB(10, 40, 80)); switch (m_state) { case GameState::Menu: m_renderer.DrawText(L"点击开始", 440, 280); break; case GameState::Playing: case GameState::Paused: m_renderer.DrawBackground(m_res.bg); m_fish.Render(m_renderer); m_bullets.Render(m_renderer); m_renderer.DrawText(FormatCoins(m_coins), 20, 20); if (m_state == GameState::Paused) m_renderer.DrawText(L"已暂停", 460, 280); break; case GameState::Over: m_renderer.DrawText(L"游戏结束,按 R 重开", 400, 280); break; } }注意Paused和Playing共用了同一套绘制代码,只在最后叠一行提示文字。这样暂停时画面不会黑掉,玩家能看清当前局面,体验上比“暂停就清屏”好很多。状态切换集中在OnKeyDown和OnLButtonDown里处理,OnFrame开头判断一次m_state,非Playing就只渲染不更新逻辑,代码路径非常清晰。
5. 排查与避坑:卡住三小时的那几个问题
5.1 先跑通最小闭环,再谈改造
拿到工程第一件事不是读代码,是编译运行。用 Visual Studio 打开.sln,确认平台工具集和项目里写的一致,字符集设成 Unicode,然后直接 F5。能出窗口、能看到鱼在动,说明环境和资源路径都没问题;出不来就按下面的顺序查:编译错误看输出窗口第一条而不是最后一条(后面的往往是第一条引发的连锁报错);链接错误看LNK2019里提到的函数名,多半是某个.cpp没加进项目;运行起来窗口一闪就没,多半是WM_CREATE里返回了 -1 或者初始化时访问了空指针。
如果用的是命令行编译,cl /EHsc /DUNICODE /D_UNICODE main.cpp GameApp.cpp Fish.cpp Bullet.cpp Renderer.cpp Resource.cpp user32.lib gdi32.lib,注意user32.lib和gdi32.lib必须显式链接,漏了会在CreateWindowEx处报未解析的外部符号。
5.2 五条真实踩坑记录
现象一:画面疯狂闪烁,鱼像在抽搐。原因是直接在WM_PAINT拿到的屏幕 DC 上先FillRect清屏再逐条画鱼,每次绘制系统都会刷新一次。解决办法是把所有绘制搬到内存 DC,WM_PAINT里只留一次BitBlt,同时把窗口类的hbrBackground设成NULL,避免系统在BeginPaint前又擦一遍。
现象二:鱼只动一下就静止,或者动得极慢。先查WndProc里有没有处理WM_TIMER,很多人写了SetTimer却忘了在窗口过程里接;再查SetTimer的返回值,返回 0 表示创建失败,通常是窗口句柄传了nullptr。还有一种情况是dt算出来接近 0,因为QueryPerformanceFrequency的返回值被存成了整数,除法时精度被截断,改成double就好了。
现象三:窗口标题和游戏内中文全是乱码。根子是 ANSI 和宽字符混用。工程里统一走W后缀的 API(CreateWindowExW、GetMessageW),字符串字面量加L前缀,项目属性里字符集选“使用 Unicode 字符集”。三者缺一个都会乱码,而且乱码形态还不一样,有的显示问号有的显示方块,光看现象很难判断是哪一环出的问题。
现象四:贴图全黑,或者LoadImage一直返回NULL。LoadImage传的是相对路径时,基准目录是进程的当前工作目录,不是 exe 所在目录。在 VS 里调试时工作目录通常是项目目录,直接双击 exe 时变成了 exe 目录,两边表现不一致,特别容易误判。稳妥做法是用GetModuleFileNameW取到 exe 全路径,截掉文件名后拼上res\\fish01.bmp,永远用绝对路径加载。
现象五:拖动窗口后鱼瞬移,炮弹穿模。系统在窗口被拖动时会暂停投递WM_TIMER,松手后把积压的消息一次性补发,dt瞬间变得很大。除了前面说的dt钳位,还可以在WM_ENTERSIZEMOVE里KillTimer、WM_EXITSIZEMOVE里重新SetTimer并重置m_lastTick,双保险。
6. 二次开发:从交作业版本到能加鱼种、加炮台的版本
这套代码最容易改的地方是鱼种表。所有鱼的类型、血量、分值、速度区间、碰撞半径、动画帧数都集中在kFishTypes这一个数组里,加一种新鱼只要往数组尾部追加一行、把对应位图丢进res目录、在.rc里加一条索引,逻辑代码一行不用动。权重数组kWeight要同步加一项,否则新鱼永远抽不到。
// 新增鱼种:只改表,不改逻辑 static const FishType kFishTypes[] = { { L"小鱼", 1, 2, 120.f, 180.f, 18.f, 4 }, { L"热带鱼", 2, 5, 90.f, 140.f, 24.f, 6 }, { L"河豚", 3, 10, 70.f, 110.f, 30.f, 6 }, { L"金鲨", 8, 50, 50.f, 80.f, 56.f, 8 }, { L"电鳗", 5, 30, 60.f, 100.f, 34.f, 6 }, // 新增 }; static const int kWeight[] = { 50, 30, 15, 5, 8 }; // 权重同步提示:
kFishTypes和kWeight的下标必须严格对应,改完记得把PickType里的循环上界从写死的4换成_countof(kFishTypes),这种写死数字的地方是后期加内容时最容易翻车的点。
炮台升级也不难。把m_cannonPower和m_cannonCost抽成等级表,每次升级读下一档,同时换一张炮台贴图,就是一套完整的成长线:
| 等级 | 伤害 | 单发消耗 | 炮台贴图 |
|---|---|---|---|
| 1 | 1 | 1 | cannon1.bmp |
| 2 | 2 | 2 | cannon2.bmp |
| 3 | 4 | 4 | cannon3.bmp |
| 4 | 8 | 8 | cannon4.bmp |
改完之后怎么验证?我一般分三步走:先看数值,在Update里临时打日志,打印每条鱼的x和vx*dt,确认位移方向和速度符合预期;再看画面,把dt人为固定成1.0/60.0跑一遍,排除时间抖动干扰;最后跑压力测试,用SpawnInitial(80)塞满屏鱼,观察帧率和碰撞是否有漏判。三步都过,基本可以放心交。
血泪经验是:每次改鱼种表之前先提交一次代码,改完发现鱼不动了,直接对比这一版和上一版的差异,比在几百行里找问题快十倍。我后来养成一个习惯,凡是动到“表和下标对应关系”的改动,改完先在纸上把下标和名字对一遍再编译——这个动作救过我至少三次。希望帮到你。
本文还有配套的精品资源,点击获取