简介:面向VC++与C++游戏开发初学者的一套双人对战游戏完整源代码,适合用来理解Windows平台小游戏的工程组织与实现逻辑。压缩包共59个文件,约57KB,包含20个地图、15个位图、7个头文件、6个cpp源文件及5个音频等,覆盖关卡数据、图形素材、声音资源与编译配置。已有407人学习,说明其作为入门案例具有一定参考价值。代码已在VC环境下调试通过,读者可据此观察类设计、对象创建、内存管理、图形渲染与事件处理等核心概念,并结合地图和音效资源理解双人对战模式的实现方式,也便于在现有工程上做二次修改与实验。
1. 两个人挤一台键盘的对战游戏,源码里到底有什么
周末想找点能两个人挤在一台机器上对打的东西练手,翻到的这份双人战斗游戏源代码,是一套很典型的 C++ 源代码工程:一个 Win32 窗口、一套 GDI 绘制、两个可选角色、血条加连击计数,不依赖任何第三方引擎,拷到装了 Visual Studio 的机器上就能编。它没有炫技的美术,值钱的地方在于把格斗游戏最核心的几件事全摊开写了——角色状态机怎么切、攻击判定按什么节奏出、两个人的按键怎么互不干扰、击退和硬直怎么落成具体数值。
这套东西适合三类人:想练 Win32 窗口编程但一直找不到合适练手项目的 C++ 新手;需要一份能现场演示对战逻辑的课程设计;以及想照着改一套自己玩法(比如横版互殴、双人闯关)的熟手。下面从工程骨架一路讲到怎么加角色、调手感。
2. VC++ 工程骨架:从 WinMain 到一个能跑的对局
先解决「能不能跑起来」这件事。这份源码的文件组织方式偏老派,没有 CMake、没有包管理器,全部交给 IDE 工程文件管理,好处是结构一目了然,坏处是换台机器容易在编译选项上翻车。
2.1 目录结构:哪些文件是引擎骨架,哪些只是素材
拿到源码包先别急着双击工程文件,把目录扫一遍比什么都强。这类小游戏工程通常按「入口 / 逻辑 / 渲染 / 输入 / 资源」五块拆,文件数不多但边界清楚:
| 文件或目录 | 职责 | 改动频率 |
|---|---|---|
GameMain.cpp | WinMain入口、窗口注册、消息循环 | 低 |
GameLoop.cpp/.h | 固定步长推进、帧计数、对局状态切换 | 中 |
Player.cpp/.h | 角色属性、状态机、受击与出招 | 高 |
Collision.cpp/.h | 矩形相交、攻击框镜像计算 | 中 |
Input.cpp/.h | 键盘状态位图、按下沿检测、双人键位表 | 低 |
Render.cpp/.h | 后台缓冲、精灵切帧、血条绘制 | 中 |
Res/*.bmp | 角色精灵表、背景、UI 素材 | 按需 |
*.vcxproj/*.sln | IDE 工程,记录编译选项与文件清单 | 一次性 |
这个划分里,Player和Collision是整份源码的重点,你以后想加招式、改手感,八成时间都花在这两个文件上。Res目录的位图通常是一张长条精灵表,每帧横向排列,靠偏移量切,不是一张一张单独的文件——这点很关键,后面讲切帧会用到。
提示:解压后先确认
Res目录的层级没被压缩工具多套一层,很多「窗口一闪就退」的问题根子就在这里。
2.2 编译选项:VC++ 工程里必须手动改的三处
老工程在新版 IDE 里打开,最常见的三个报错都来自配置差异。第一处是字符集:这类代码大量使用char*和LoadImageA风格调用,工程属性里的字符集要设成「使用多字节字符集」,否则一路C2664无法从const char*转换到LPCWSTR。第二处是附加依赖库:循环里用timeGetTime计时就得链winmm.lib,用TransparentBlt做色键透明就得链msimg32.lib。第三处是调试工作目录:默认是工程文件所在目录,而资源路径写的是相对路径,F5 启动时找得到图,双击 exe 就找不到。
不想开 IDE 的话,可以从命令行直接编,把依赖显式写出来,问题反而少:
:: 在开发者命令行里执行,先 cd 到工程根目录 cl /nologo /EHsc /MT /DWIN32 /D_WINDOWS ^ GameMain.cpp GameLoop.cpp Player.cpp ^ Collision.cpp Input.cpp Render.cpp ^ /link user32.lib gdi32.lib winmm.lib msimg32.lib ^ /SUBSYSTEM:WINDOWS /OUT:Battle.exe参数逐条说:/EHsc打开标准 C++ 异常模型,虽然这份代码基本不用异常,但不加会有警告刷屏;/MT静态链接运行库,编出来的 exe 拷到没装运行库的机器上也能跑,分发演示时特别省事;/SUBSYSTEM:WINDOWS告诉链接器入口是WinMain而不是main,漏了这行会报LNK2019: unresolved external symbol _main,这是新手最容易卡住的一条。最后那个/OUT只影响产物名,不改也无所谓。
编过之后先别急着改成自己的东西,用原版跑一局,确认两个人的按键都能动、能打中、血条会掉。这一步是后面所有改动的基准线,跳过它,后面出问题你会分不清是自己改坏的还是本来就坏的。
2.3 主循环:PeekMessage 与 60Hz 固定步长的配合
游戏循环有两种写法:GetMessage阻塞式和PeekMessage非阻塞式。对战游戏必须用后者,画面要一直刷新,不能等消息。但只把PeekMessage塞进while里还不够,直接用「每帧走一次逻辑」的写法,在 144Hz 显示器上角色移动速度会比 60Hz 快一倍多,这种因为刷新率不同导致的手感变化,很容易让人摸不着头脑。
稳妥的做法是逻辑按固定步长推进,绘制跟随刷新:
// GameLoop.cpp —— 固定步长主循环 const int STEP_MS = 1000 / 60; // 逻辑帧 60Hz,和显示器刷新率解耦 void RunGameLoop() { DWORD last = timeGetTime(); int acc = 0; MSG msg; while (g_running) { // 1. 先把消息队列清空,窗口拖动、缩放才不会卡死 while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { if (msg.message == WM_QUIT) { g_running = false; break; } TranslateMessage(&msg); DispatchMessage(&msg); } // 2. 累计真实流逝时间,够一个逻辑帧就走一步 DWORD now = timeGetTime(); acc += (int)(now - last); last = now; if (acc > 200) acc = 200; // 卡顿保护:切出去再回来别追帧雪崩 while (acc >= STEP_MS) { UpdateGame(STEP_MS); // 状态机、物理、判定全在这里 acc -= STEP_MS; } RenderGame(); // 绘制用最新状态,不参与逻辑 Sleep(1); // 让出 CPU,避免单核跑满风扇狂转 } }逻辑说明:acc是时间累加器,它记录「距离上次处理已经过去了多少毫秒」。while (acc >= STEP_MS)保证无论机器快慢,逻辑层看到的永远是每次 16ms 的推进量,角色速度、出招时长都不会因为硬件而变。acc > 200那行的封顶很重要,窗口被拖动或者切到别的程序,timeGetTime的差值可能是好几秒,不封顶就会在一帧里连跑几百次UpdateGame,表现就是切回来瞬间角色瞬移甚至直接穿模飞出屏幕。
参数说明:STEP_MS改成1000 / 30会让所有动作变慢一倍、判定窗口变宽,是快速做「慢动作调试」的土办法;Sleep(1)拿掉 CPU 占用会从个位数爬到二十几,笔记本上风扇声立刻能听出来。UpdateGame内部不要调用任何GetTickCount之类的时间函数,所有时间参数都从传入的dt走,这条纪律守住了,后面加回放、加暂停都不用返工。
3. 核心玩法拆解:状态机、双人输入与攻击判定
跑通之后才是真正有意思的部分。格斗游戏看着复杂,拆开就是四件事:角色在什么状态、这一帧有没有判定、打中了扣多少血、被打的人往哪飞。这份源码把这四件事分在四个函数里,互相之间只通过结构体传值,读起来不绕。
3.1 角色状态机:九种状态和它们的合法转移
角色的一切行为都归到一个枚举里,任何时刻只能处于其中一个状态,切换必须走统一的入口函数。这种写法的好处是「能不能出招」这类规则只在一个地方判断,不会出现走动中还能出重击、倒地了还能反击的荒唐情况:
// Player.h —— 状态枚举,数值会被写进回放文件,顺序别随手改 enum ActorState { ST_IDLE = 0, // 站立待机 ST_WALK, // 前后移动 ST_JUMP, // 起跳与滞空 ST_ATTACK_L, // 轻攻击 ST_ATTACK_H, // 重攻击 ST_BLOCK, // 格挡 ST_HURT, // 受击硬直 ST_DOWN, // 倒地,起身前无敌 ST_COUNT }; // Player.cpp —— 每逻辑帧调用一次,dt 固定 16ms void Player::Tick(int dt) { animTimer += dt; // 当前状态已经持续了多久 switch (state) { case ST_IDLE: if (IsKeyDown(ctrl.left) || IsKeyDown(ctrl.right)) Enter(ST_WALK); else if (IsKeyPressed(ctrl.heavy)) Enter(ST_ATTACK_H); else if (IsKeyPressed(ctrl.light)) Enter(ST_ATTACK_L); break; case ST_WALK: x += (IsKeyDown(ctrl.left) ? -walkSpeed : walkSpeed) * dt / 16; if (!IsKeyDown(ctrl.left) && !IsKeyDown(ctrl.right)) Enter(ST_IDLE); break; case ST_ATTACK_L: case ST_ATTACK_H: // 出招期间不接受新输入,走完总时长才回待机——这就是「后摇」 if (animTimer >= curMove.totalMs()) { Enter(ST_IDLE); } break; case ST_HURT: if (animTimer >= hurtMs) Enter(IsDead() ? ST_DOWN : ST_IDLE); break; case ST_DOWN: if (animTimer >= 800) { hp = maxHp / 3; Enter(ST_IDLE); } // 起身回一点血 break; } } void Player::Enter(ActorState s) { state = s; animTimer = 0; // 一切换就重置计时,否则动画会跳帧 frameIdx = 0; }逻辑说明:animTimer是状态的本地时钟,每次Enter清零,这一点必须严格执行。早期版本漏了在Enter里重置,结果从受击切回待机的瞬间animTimer还带着上一次的余量,下一帧立刻满足结束条件,角色像抽搐一样闪。参数说明:walkSpeed是像素每逻辑帧,通常 2~4 之间;ST_DOWN里那个 800ms 是起身时间,改小会变成无限连的温床,改大会让一局拖很久。想加招就在枚举里插一项、在switch里加分支,别在Tick里堆if。
3.2 双人输入:两套键位、一份状态位图
两个人共用一块键盘,最容易翻车的是「一个人按的键影响另一个人」。正确做法是共用一份键盘状态位图,但查询时通过各自的键位表隔离,谁也不会读到对方的键:
// Input.h —— 每个玩家一套键位,用虚键码而不是字符,避免大小写问题 struct KeyMap { int left, right, up, down; int light, heavy, block; }; static const KeyMap kP1 = { 'A', 'D', 'W', 'S', 'J', 'K', 'L' }; static const KeyMap kP2 = { VK_LEFT, VK_RIGHT, VK_UP, VK_DOWN, VK_NUMPAD1, VK_NUMPAD2, VK_NUMPAD3 };// Input.cpp —— 每逻辑帧只采样一次,按下沿靠新旧两张表对比 static BYTE curKey[256], prevKey[256]; void InputPoll() { memcpy(prevKey, curKey, sizeof(curKey)); // 上一帧的快照 GetKeyboardState(curKey); // 高位为 1 表示按住 } bool IsKeyDown(int vk) { return (curKey[vk] & 0x80) != 0; } bool IsKeyPressed(int vk) { return IsKeyDown(vk) && !(prevKey[vk] & 0x80); }逻辑说明:IsKeyPressed只在「上一帧没按、这一帧按住」时为真,这就是按下沿。用IsKeyDown做出招判断,按住不动会每帧触发一次,格斗游戏里表现为放出一串连击,看起来像角色不受控地乱打——这是新手改这套代码最常犯的错,务必分清两个函数的语义。参数说明:VK_NUMPAD1是小键盘数字键,笔记本没小键盘的话,把 P2 那行换成'1','2','3'或者方向键加VK_RSHIFT组合都行,注意别和 P1 的字母键撞车。GetKeyboardState读的是全局键盘状态,窗口失焦时也会更新,所以在WM_KILLFOCUS里把curKey清零,切出去再切回来不会出现角色一直往前跑的怪事。
3.3 攻击判定:帧数据表加轴对齐矩形
判定这块,很多小项目会写成「按下攻击键,检查对方距离小于 80 像素就算打中」,能跑但完全没有手感,因为你无法区分起手、判定持续和收招。这份源码用的是帧数据表加矩形相交,招式数值全表驱动,改平衡不用碰逻辑代码:
// MoveTable.h —— 一招一行,改数值就是调平衡 struct MoveDef { const char* name; int startupMs; // 起手:这段时间没有任何判定 int activeMs; // 判定持续:只有这段才可能打中 int recoveryMs; // 收招:完全不能动,被打就吃满伤害 int damage; int hitStunMs; // 打中后对手硬直多久 int knockBack; // 水平击退像素 RECT hitBox; // 相对角色原点的攻击框,左上右下 };// Collision.cpp —— 轴对齐矩形相交,够用且好调 static bool RectHit(const RECT& a, const RECT& b) { return a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; } // 攻击框必须按朝向镜像,否则朝左时永远打不到人 RECT WorldHitBox(const Player& p, const MoveDef& m) { RECT r = m.hitBox; if (p.facing < 0) { int w = r.right - r.left; r.left = -r.right; // 以原点为轴翻到左边 r.right = r.left + w; } OffsetRect(&r, p.x, p.y); // 平移到世界坐标 return r; }逻辑说明:判定分两个阶段做,先在ST_ATTACK_x状态里用animTimer判断当前处在起手、判定还是收招,只有落在[startupMs, startupMs + activeMs)区间内才去算矩形。这样一套「起手 120ms、判定 60ms、收招 200ms」的招式,玩家能明确感受到「按下去有延迟、打到有瞬间、打完有僵直」,手感就是这么堆出来的。参数说明:hitBox用相对坐标,原点取角色脚底中线,这样角色移动时不用重算表;facing在每次左右移动时同步更新,受击后锁定朝向防止背身连打;RectHit只做 AABB,对矩形人物完全够,想更精细可以给每个部位加一个框,但那是另一个量级的活。
注意:
WorldHitBox里镜像后的r.left是负数、r.right更小,OffsetRect之后再相交没问题,但如果你的RectHit假设了left < right,镜像就会失效。踩过一次,找了半天以为是判定帧写错了。
3.4 数值汇总:伤害、硬直和击退怎么串起来
判定命中之后做的事,决定了这个游戏是「互殴」还是「格斗」。核心是把攻击方的招式和受击方的状态关联起来,同时保证结局明确:
// Player.cpp —— 受击处理,dirFromAttacker 由攻击方的 facing 决定 void Player::TakeHit(const MoveDef& m, int dirFromAttacker) { if (state == ST_DOWN) return; // 倒地无敌,防止鞭尸 if (state == ST_BLOCK && IsFacing(dirFromAttacker)) { hp -= m.damage / 5; // 格挡只吃两成伤害 hurtMs = m.hitStunMs / 2; // 硬直也减半,能反击 } else { hp -= m.damage; hurtMs = m.hitStunMs; vx = m.knockBack * dirFromAttacker; // 击退给一个初速度 } if (hp <= 0) { hp = 0; Enter(ST_DOWN); } else { Enter(ST_HURT); } animTimer = 0; hitFlash = 120; // 受击闪白 120ms,纯表现 }逻辑说明:vx是速度,交给物理更新去做衰减,比如每帧vx *= 0.85并叠加到x上,击退会有一段自然的滑行而不是瞬移。格挡判断里带朝向检查是必须的,否则背对着人也算格挡,对局会变成两个人都按着格挡键互相无事发生。参数说明:damage / 5这种硬编码比例偷懒但有效,正规做法是给MoveDef加一个blockDamage字段;hitFlash只影响渲染,和逻辑解耦,删掉也不会改变平衡。血量建议用整数,浮点数在反复加减里会出现 0.0001 这种残血,判定死亡时容易漏。
4. 渲染层:GDI 双缓冲把画面稳住
逻辑跑通以后,画面质量直接决定这东西能不能拿出去给人看。GDI 单缓冲每帧先擦背景再画角色,中间那一瞬间会被显示器捕捉到,表现就是角色边缘像描了一圈黑边,俗称闪烁。解决办法就一层后台缓冲。
4.1 双缓冲:把闪烁压到零的一次 BitBlt
缓冲区的创建一定要放在窗口初始化阶段,并且尺寸变了才重建。很多实现图省事在每帧CreateCompatibleBitmap一次,跑起来内存曲线跟心电图一样,长时间玩会越来越卡:
// Render.cpp —— 一次创建,窗口尺寸不变就一直复用 static HDC memDC = NULL; static HBITMAP memBmp = NULL; static int bufW = 0, bufH = 0; void RenderBegin(HDC hdc, int w, int h) { if (!memDC) { memDC = CreateCompatibleDC(hdc); memBmp = CreateCompatibleBitmap(hdc, w, h); SelectObject(memDC, memBmp); bufW = w; bufH = h; } HBRUSH bg = CreateSolidBrush(RGB(24, 24, 32)); // 每帧统一填底 RECT rc = { 0, 0, w, h }; FillRect(memDC, &rc, bg); DeleteObject(bg); } void RenderEnd(HDC hdc) { BitBlt(hdc, 0, 0, bufW, bufH, memDC, 0, 0, SRCCOPY); // 一次贴上去 }逻辑说明:所有绘制都发生在memDC上,玩家完全看不到中间过程,BitBlt那一下才是真正上屏的瞬间,闪烁就这么消失了。参数说明:CreateSolidBrush每帧建一次、用完立刻DeleteObject,这是 GDI 对象的硬性要求,漏删几十万个句柄之后整个进程会变得极慢;更省的做法是把这个画刷做成静态全局,初始化建一次,退出时销毁。WM_SIZE里要把memDC和memBmp释放重置,否则窗口拉大之后右下角会出现没被刷新的残留区域,看起来像贴图撕裂。
4.2 精灵切帧与色键透明
角色精灵图是一张长条位图,每帧等宽。切帧就是按「当前动画第几帧 × 帧宽」算出源矩形,再贴到目标位置。素材背景通常是一个不常用的纯色(洋红或纯黑),用色键贴图直接抠掉:
// Render.cpp —— 从精灵表切出第 idx 帧贴到屏幕 void DrawFrame(HDC dst, HBITMAP sheet, int idx, int dstX, int dstY, int fw, int fh, bool flip) { HDC src = CreateCompatibleDC(dst); SelectObject(src, sheet); int sx = idx * fw; // 源图横向偏移 if (!flip) { TransparentBlt(dst, dstX, dstY, fw, fh, src, sx, 0, fw, fh, RGB(255, 0, 255)); } else { // 朝左时用 StretchBlt 负宽度做水平翻转 StretchBlt(dst, dstX + fw, dstY, -fw, fh, src, sx, 0, fw, fh, SRCCOPY); } DeleteDC(src); }逻辑说明:TransparentBlt的最后一个参数是色键,源图里等于这个颜色的像素不会被画出来,角色因此有了轮廓。翻转用负宽度StretchBlt是最省事的做法,不用准备两套素材。参数说明:fw/fh必须和精灵表的实际帧尺寸严格一致,差一个像素整张图就会错位,表现是角色一半是头一半是脚,这种问题肉眼一看就知道,但改素材的时候经常忘同步。CreateCompatibleDC每帧建一次DeleteDC收掉,图省事也可以缓存,前提是同一张表只对应一个 DC。
4.3 逻辑帧与绘制帧分离带来的手感差异
这一节没有代码,但值得单独说。前面主循环已经把逻辑锁在 60Hz,绘制却是跟着窗口刷新走的。这意味着在高刷新率屏幕上,同一份逻辑状态可能被画两次,画面看起来更顺,但出招时长、硬直、击退距离全都不变——这正是我们想要的。反过来,如果把动画帧推进写在渲染里(RenderGame里自增frameIdx),在 144Hz 上动画会快 2.4 倍,招式还没打到人就已经播完了,这类问题靠调数值是修不好的。
我的习惯是动画帧号也在Tick里推进,RenderGame只读不写,纯函数式。这样加个暂停功能只要停掉UpdateGame,画面自然定格;做回放只要把每帧的输入存下来重放,画面完全可复现。这份源码基本是这个结构,改动时守住它,后面能省很多事。
5. 避坑与排查:几处让我卡了半天的坑
下面这些是实际动手时会撞上的,按「现象 → 原因 → 解决」记,省得你再走一遍。
5.1 编译与启动阶段
现象:链接报LNK2019: unresolved external symbol _timeGetTime@0,代码里明明包含了头文件。原因:timeGetTime属于多媒体计时器,声明在头文件里但实现在winmm.lib,不显式链接就找不到符号。解决:工程属性 → 链接器 → 输入 → 附加依赖项加winmm.lib,或者代码里加#pragma comment(lib, "winmm.lib"),后者更省事,换机器不用重新配。
现象:F5 能跑,把 exe 拷到别的目录双击就黑屏退出。原因:资源用的是"Res\\hero.bmp"这种相对路径,相对的是「当前工作目录」而不是 exe 所在目录,IDE 启动时工作目录是工程目录,恰好对得上。解决:加载资源前用GetModuleFileName拿到 exe 全路径,截掉文件名拼上Res,这样在哪儿双击都能找到图。
现象:一堆C2664 无法将参数从 const char* 转换为 LPCWSTR。原因:新版 IDE 默认 Unicode 字符集,而老代码里全是char*和 ANSI API。解决:工程属性 → 常规 → 字符集改成「使用多字节字符集」,比把几百处字符串改成_T()宏快得多。
5.2 对局阶段
现象:按住攻击键,角色像抽风一样连续出招,根本停不下来。原因:出招判断写成了IsKeyDown,按住期间每帧都满足条件,收招一结束立刻又进入下一招。解决:改成IsKeyPressed做按下沿,或者加一个「必须松开再按」的锁存标志。
现象:角色朝左站的时候怎么打都打不中人,朝右一切正常。原因:攻击框是相对坐标,朝左时没有做镜像,判定框还留在角色右边。解决:按facing翻转hitBox的左右边界,翻完记得OffsetRect,并且确认相交函数没有假设left < right。
现象:切出去看个网页再切回来,角色直接飞出屏幕。原因:timeGetTime在窗口失焦期间照样走,累加器攒了几千毫秒,一回来连跑上百次逻辑更新。解决:累加器封顶(前面代码里的if (acc > 200) acc = 200),并在WM_ACTIVATE里把last重置成当前时间。
现象:玩十几分钟以后越来越卡,任务管理器里 GDI 对象数一路涨。原因:每帧CreateSolidBrush或CreateCompatibleDC之后没DeleteObject/DeleteDC。解决:把长期不变的 GDI 对象提到初始化阶段创建,退出时统一销毁;临时对象确保成对释放。
6. 二次开发:换一套玩法的最小改动路径
真要动手改,我建议从「加一个新招式」开始,而不是一上来就加角色,因为加招式的路径覆盖了数据表、状态机、判定、动画四条链路,跑通一遍就摸清了整套结构。具体三步:在MoveDef表里追加一行,写清startupMs、activeMs、recoveryMs、damage、hitBox;在ActorState里加一个状态(或者复用轻攻击状态、靠curMoveIndex区分);在输入检测里加一个按键分支,并在Enter时把curMove指向新表项。改完先只填数值不换素材,用原来的动画跑一遍,能打中、有击退、血条会掉,说明链路是通的,再换图。
想加角色,改动量集中在三个地方,列个表方便对照:
| 改动点 | 具体动作 | 容易漏的地方 |
|---|---|---|
| 角色属性 | 新建Player实例,配maxHp、walkSpeed | 血条绘制用的上限别写死 |
| 素材 | Res里加一张同规格精灵表 | 帧宽高要和代码里的常量一致 |
| 招式表 | 复制一份MoveDef数组按角色区分 | 受击时要用「被打者」的表判断朝向 |
| 选人界面 | 无的话直接写死两个角色 | 别在两个角色间共享可变状态 |
验证方法很土但有效:先把STEP_MS调成1000 / 20让游戏进入慢动作,站在屏幕中间出一招,看判定框是不是在起手之后才出现、收招期间是不是真的不能动。这两点对了,招式的「节奏感」就成立了;节奏感对了,数值怎么调都不会太离谱。想验证浮空和连段,临时把hitStunMs调到 600,如果能打出两段,说明状态机在受击期间正确地拒绝了新输入。
还有一个我踩过的坑值得提前说:改ActorState枚举时,如果项目里已经做了输入回放或存档,千万不要在中间插入新值,只能在末尾追加,否则老回放全部错位,看起来像角色在乱动,要查很久才能想到是枚举序的问题。
从那以后我每次动状态机,都会先在纸面上把状态图画一遍,确认每个状态都有进有出、没有孤立节点,再去敲代码。这个方法很笨,但能挡掉大部分「角色卡在某个状态出不来」的玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取