ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VC++双人战斗游戏源代码:GDI双缓冲、状态机与AABB判定

VC++双人战斗游戏源代码:GDI双缓冲、状态机与AABB判定

简介:这是一份面向C++初学者与游戏开发入门者的VC++双人对战游戏完整源代码,已在Visual C++环境下调试通过,可用于学习类设计、对象创建、内存管理、图形渲染与事件处理等核心概念。压缩包共59个文件,约57KB,包含6个cpp源文件与7个头文件构成程序主体,20个map地图文件、15个bmp位图、5个wav音效及rc、ico、dsp等工程与资源文件,覆盖关卡、图像、声音和编译配置,目录完整便于按模块研读。已有407人学习,说明其作为小型实战案例具备一定参考价值。读者可从中了解双人战斗模式的实现思路、20幅地图的关卡组织方式以及VC++工程配置方法,并通过编译运行、修改地图或调整音效来观察代码变化,适合作为课程设计、自学练手和游戏开发入门的分析样本。

1. 从一份 .dsw 工程说起:这包双人战斗游戏源代码能跑出什么

把压缩包解开,第一眼看到的是一份 VC C++ 源代码:一个 VC++ 6.0 时代的 .dsw 工程文件、一个塞满 96×96 精灵表的 res 目录、两份写着角色数值的 ini,双击编译出来的 exe,两个像素小人站在同一块平地上,1P 用 WASD 加 J、K,2P 用方向键加数字键,一招一式地互相对打。这份双人战斗游戏源代码的定位非常朴素:它不是一个游戏引擎,没有网络同步,没有 AI 对手,就是把「移动、出招、判定、扣血、硬直、KO、重开」这一整条闭环完整跑通的最小工程。适合谁?想搞明白 2D 格斗游戏从输入到判定的最小实现路径的人,手上有个课程作业或者原型要交的人,以及还在维护老 VC++ 工程、需要一份敢放手去改的练手代码的人。后面几章就按「工程怎么搭、人怎么动、打怎么算、坑在哪、怎么改」的顺序往下拆。

2. 工程骨架与渲染底座:MFC 外壳里塞一个 GDI 双缓冲循环

这个项目的骨架没什么花活,一个 Win32 窗口加一个游戏主控类,所有逻辑都挂在主控类的 Update 和 Render 两个函数上。理解这一层,后面看角色、看判定才不会迷路。

2.1 目录长什么样:src、res、data 三块要分清

老工程最容易让人翻车的地方不是代码难,是文件散。我拿到手第一件事永远是把目录列一遍,看清楚哪些是代码、哪些是资源、哪些是配置。这个包的典型结构长这样:

路径作用
FightGame.dsw / .dspVC++ 6.0 工程文件,较新版本的 IDE 打开会提示转换
src/main.cppWinMain、窗口类注册、消息循环入口
src/GameApp.cpp游戏主控:初始化、帧步进、绘制调度、退出清理
src/Renderer.cpp双缓冲、位图加载、精灵绘制、文字绘制
src/Input.cpp键盘状态表与边沿检测
src/Fighter.cpp角色实体:动画、状态机、坐标、血量
src/Battle.cpp攻防判定、伤害结算、回合与胜负
src/Config.cppini 读取封装
res/*.bmp精灵表,横向排帧,品红底做透明色
res/*.wav打击、受击、KO 音效
data/fighters.ini每个角色的单元格尺寸、帧数、速度、伤害
data/stage1.ini背景图、地面高度、双方出生点

src 和 data 的边界是这套代码里最值得学的一点:凡是数值,一律进 ini;凡是代码,一律只处理流程。你后面加角色、调手感,九成工作都在 data 里完成,不用重新编译。这条规矩在很多小游戏工程里是被破坏的——速度写死在 Update 里、伤害写死在判定函数里——一旦要改就得到处 grep。

2.2 消息循环改成游戏循环:PeekMessage 加固定步长

原生的 Windows 程序是被消息驱动的,没消息就趴着不动。游戏不行,游戏必须每帧都动。所以主循环要从 GetMessage 换成 PeekMessage:有消息就处理,没消息就继续跑逻辑和绘制。

// main.cpp —— 用 PeekMessage 把消息泵改造成不等消息的游戏循环 int APIENTRY WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int) { GameApp app; if (!app.Init(hInst, 960, 540)) // 建窗口、建双缓冲、加载资源 return -1; MSG msg = {0}; DWORD last = GetTickCount(); // 上一帧时间戳,毫秒 const DWORD frameMs = 16; // 目标 60 帧,一帧 16ms while (msg.message != WM_QUIT) { if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) // 先把消息清空 { TranslateMessage(&msg); DispatchMessage(&msg); continue; } DWORD now = GetTickCount(); while (now - last >= frameMs) // 用 while 补帧,掉帧时不拖慢逻辑 { app.Update(frameMs); // 逻辑步进,步长固定 last += frameMs; } app.Render(); // 画到内存 DC,最后一次性贴屏 } app.Shutdown(); return 0; }

这段代码有三个参数值得单独说。frameMs 决定了逻辑帧率,16 对应 60 帧,你要是把它改成 33,角色移动速度、动画播放速度会整体慢一半,因为位移是按「每帧走多少像素」写的,不是按「每秒走多少像素」。所以改帧率之前,必须先把所有速度值按比例重算一遍。last 用 DWORD 累加而不是每帧重置成 now,是为了让补帧逻辑不丢时间片;如果写成 last = now,掉一帧就永久少跑一帧逻辑,长时间跑下来角色位置会和动画对不上。GetTickCount 的精度只有 15ms 左右,对格斗游戏来说够用但不理想,讲究一点的做法是换 timeGetTime 或者 QueryPerformanceCounter,这个包里留了注释提示,但默认实现用的是 GetTickCount,你二次开发时可以考虑替换。

为什么不用 SetTimer?因为 WM_TIMER 是优先级最低的消息,窗口一拖动、一缩放,系统就会把它合并甚至丢弃,表现出来就是画面卡住然后瞬移。这是老 VC++ 游戏工程里最常见的错误写法,如果你手上的版本用的是 SetTimer,建议直接照上面这段改掉。

2.3 双缓冲为什么必须做:闪烁的来源与内存 DC

直接在主窗口 DC 上画精灵,一定会闪。原因很直白:你先把背景刷成纯色,再一张张贴图,贴到第三张的时候屏幕已经刷新了一次,玩家看到的就是背景和角色交替闪现。解决办法是画在一块看不见的位图上,画完再整块拷过去。

// Renderer.cpp —— 双缓冲:所有绘制都落在 memDC,最后一次性贴屏 void Renderer::BeginFrame() { // 每帧先把内存 DC 清成背景色,等价于「清屏」 FillRect(m_memDC, &m_clientRect, m_backBrush); } void Renderer::EndFrame(HDC hdc) { // 全流程只有这一次真正写屏,闪烁从原理上被消除 BitBlt(hdc, 0, 0, m_width, m_height, m_memDC, 0, 0, SRCCOPY); }

m_memDC 和 m_memBmp 在 Init 里用 CreateCompatibleDC(hdc) 和 CreateCompatibleBitmap(hdc, w, h) 建一次,SelectObject 挂上去之后整个生命周期都不再重建;m_backBrush 是 CreateSolidBrush 造的画刷,颜色就是背景底色。窗口尺寸变化时要重建这两样东西,顺带把 m_clientRect 更新,否则拉伸窗口之后画面会被裁掉一块。

精灵贴图用 TransparentBlt,第五个参数传品红 RGB(255,0,255),位图里所有品红像素会被跳过。这个函数在 msimg32.lib 里,工程里没链接的话会报 LNK2019,后面避坑章节会细说。

至于为什么用 GDI 而不是 DirectDraw 或者 Direct3D:这份代码诞生的年代,GDI 是零依赖、零额外库的最优解,960×540 分辨率下贴二三十张 96×96 的图,CPU 占用也很低。你要是打算把它扩到 1080p 加粒子特效,GDI 会成为瓶颈,那时候再换 D3D 或者换引擎,而不是现在就纠结。

3. 角色是怎么动起来的:输入表、精灵切帧与状态机

渲染层解决的是「画得出来」,这一层解决的是「动得对」。格斗游戏的手感好坏,八成取决于这一层写得干不干净。

3.1 输入层:一张 256 项的表接住两个人同时按

键盘消息是 WM_KEYDOWN / WM_KEYUP,但游戏逻辑不能直接挂在消息上——消息是离散的,逻辑是每帧轮询的。所以中间要有一层状态表,把「事件」转成「状态」。

// Input.cpp —— 按下/抬起记录状态,另存一份上一帧用于边沿检测 void Input::OnKeyDown(WPARAM vk) { if (vk < 256) m_down[vk] = true; } void Input::OnKeyUp(WPARAM vk) { if (vk < 256) m_down[vk] = false; } bool Input::Down(int vk) const // 当前是否按住:用于移动 { return vk >= 0 && vk < 256 && m_down[vk]; } bool Input::Pressed(int vk) const // 本帧是否刚按下:用于出招 { return vk >= 0 && vk < 256 && m_down[vk] && !m_prev[vk]; } void Input::EndFrame() // 每帧逻辑跑完后调用,快照一次 { memcpy(m_prev, m_down, sizeof(m_down)); }

Down 和 Pressed 的区别是这套代码里最容易被忽略的细节。移动必须用 Down,因为玩家按住方向键要一直走;出招必须用 Pressed,否则按住攻击键会连续触发几十次攻击,一秒钟打掉对面满血。EndFrame 的调用位置很关键,必须放在所有角色 Update 之后、Render 之前,早了会让同一帧里的第二个角色读不到边沿,晚了会让边沿多存活一帧。

键位映射单独抽出来,两个玩家各一份结构体:

// 默认键位,实际工程里从 data/keymap.ini 读,这里给出常用默认值 struct KeyMap { int left, right, up, down, 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, '1', '2', '3' };

用结构体而不是散落的 if 判断,好处是加手柄、加宏、加重映射的时候只改这一处。我在别的项目里见过把键位硬编码成十几个if (vk == 'A')的写法,加个 2P 就得复制一遍整段逻辑,改一次键位要动二十行。

3.2 精灵表切帧:从帧号到源矩形的换算

所有角色动画都是一张横向排布的位图,第 n 帧就是从左边数第 n 个格子。换算只有一行乘法,但格子尺寸必须和 ini 里写的一致,差一个像素,整个角色就会逐帧抖动。

// Fighter.cpp —— 帧号换算成源矩形,cellW/cellH 来自 fighters.ini RECT Fighter::FrameRect(int frame) const { RECT r; r.left = frame * m_cellW; // 例如 96 r.top = 0; r.right = r.left + m_cellW; r.bottom = r.top + m_cellH; // 例如 96 return r; }

真正贴图的时候还要处理朝向。最省事的做法不是运行时镜像,而是让资源在导入阶段就多生成一张水平翻转的图,绘制时按 m_facingRight 选位图:

// Renderer.cpp —— 透明贴图,按朝向选原图或翻转图 void Renderer::DrawSprite(int x, int y, const Sprite& sp, int frame, bool facingRight) { RECT src = sp.FrameRect(frame); int w = src.right - src.left; int h = src.bottom - src.top; HBITMAP bmp = facingRight ? sp.bitmap : sp.flipBitmap; HBITMAP old = (HBITMAP)SelectObject(sp.dc, bmp); TransparentBlt(m_memDC, x, y, w, h, sp.dc, src.left, src.top, w, h, sp.maskColor); SelectObject(sp.dc, old); }

注意 sp.dc 是加载资源时就建好并缓存的,不是每次绘制都 CreateCompatibleDC。有次我在一个类似工程里看到绘制函数内部每帧 CreateCompatibleDC 加 DeleteDC,两个角色各画一次,一秒 120 次 DC 创建销毁,帧率直接掉到 30 以下。DC 创建是内核对象操作,比贴图本身贵得多,这个成本必须前置到加载阶段。

为什么不用 StretchBlt 负宽度做镜像?因为它会把透明色一起画上去,角色周围会多出一个品红方块。要用镜像加透明,得先画到临时位图再做掩码,绕一大圈,还不如多存一张图。

3.3 状态机:IDLE、WALK、ATTACK、HURT、DEAD 之间的迁移条件

角色在任何时刻只能处于一种状态,这是格斗游戏不会出现「边跑边砍」的前提。状态机写成 switch,每帧只做一次判断,逻辑清楚到可以当伪代码看。

// Fighter.cpp —— 每帧根据输入和当前状态决定下一个状态 void Fighter::Update(const Input& in, const KeyMap& key, int stageLeft, int stageRight) { switch (m_state) { case ST_IDLE: if (in.Pressed(key.light)) { EnterState(ST_ATTACK); } else if (in.Down(key.left) || in.Down(key.right)) { EnterState(ST_WALK); } else if (in.Pressed(key.up)) { EnterState(ST_JUMP); } break; case ST_WALK: m_x += in.Down(key.right) ? m_walkSpeed : -m_walkSpeed; if (!in.Down(key.left) && !in.Down(key.right)) EnterState(ST_IDLE); if (in.Pressed(key.light)) { EnterState(ST_ATTACK); } break; case ST_ATTACK: m_anim.Tick(); // 推进攻击动画帧 if (m_anim.Finished()) EnterState(ST_IDLE); // 播完自动回站立 break; case ST_HURT: m_anim.Tick(); m_vx *= 0.8f; // 击退速度逐帧衰减 m_x += (int)m_vx; if (m_anim.Finished()) EnterState(ST_IDLE); // 硬直结束 break; case ST_DEAD: m_anim.Tick(); // 只播动画,不响应任何输入 break; } m_anim.ApplyTo(*this); // 用当前帧数据推动坐标/贴图偏移 ClampToStage(stageLeft, stageRight); // 别让人走出场景边界 }

EnterState 是这套设计的关键,它承担了所有「进状态时的副作用」:重置动画帧号、把速度清零、设置无敌帧、切换音效。如果这些散落在各个 case 里,改一次手感要动五六个地方。

// Fighter.cpp —— 进状态统一在这里处理,避免副作用散落 void Fighter::EnterState(State s) { if (m_state == s) return; m_state = s; m_anim.Reset(m_stateFrameCount[s]); // 按 ini 里的帧数重设动画 m_vx = 0; // 速度一律清零,由各状态自己赋 }

把 m_vx 清零这件事看起来多余,实际上是无数格斗游戏「走路时被打飞、落地后还在滑」的根源。进入 HURT 时如果不重置速度,上一帧的走路速度会叠加到击退速度上,人被打一下能飞半屏。

3.4 数据驱动:帧数、速度、伤害全部搬进 ini

前面反复提到 ini,这里给一份实际的样子。VC++ 自带 GetPrivateProfileInt / GetPrivateProfileString,不引第三方库就能读,对这个体量的项目完全够用。

; data/fighters.ini —— 一个角色一段,改数值不用重编译 [player1] cell_w=96 cell_h=96 idle_frames=4 walk_frames=6 attack_frames=6 attack_hit_start=3 attack_hit_end=5 hp=100 walk_speed=4 damage=8 knockback=6

attack_hit_start 和 attack_hit_end 是判定窗口,意思是攻击动画只在第 3 到第 5 帧之间才有伤害判定,前 2 帧是抬手,后 1 帧是收招。这三个数字直接决定手感:窗口越窄越「硬核」,越宽越「好打中」。把它们放进配置而不是写死在判定函数里,调手感就变成了改数字重开游戏,而不是改代码重编译——这个差别在实际开发里非常巨大。

4. 打得中才算数:碰撞盒、伤害结算与资源组织

角色能动之后,下一个问题就是「打没打中」。这一层写不好,玩家会觉得判定很玄学,明明看着挨上了却没事,或者隔着半个身位被打飞。

4.1 AABB 与判定窗口:攻击只在第 3 到第 5 帧生效

判定用最朴素的轴对齐矩形(AABB),不搞多边形、不搞像素级,因为 2D 格斗到这个精度就够了,而且 AABB 计算量小到可以忽略。

// Battle.cpp —— 攻击判定:状态、帧窗口、单次命中三个条件缺一不可 bool Battle::CheckHit(const Fighter& a, Fighter& b) { if (a.State() != ST_ATTACK) return false; int f = a.CurrentFrame(); if (f < a.HitStart() || f > a.HitEnd()) return false; // 不在判定帧内 if (a.HasHitThisAttack()) return false; // 一次攻击只中一次 RECT hit = a.HitBox(); // 攻击盒,随朝向翻转 RECT body = b.BodyBox(); // 受击盒,比贴图小一圈 if (!Intersect(hit, body)) return false; b.TakeDamage(a.Damage(), a.FacingRight() ? 1 : -1); a.MarkHit(); // 立刻标记,防止同一帧重复命中 return true; } static bool Intersect(const RECT& a, const RECT& b) { return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; }

HasHitThisAttack 和 MarkHit 这一对,是防止「一次攻击扣三次血」的后悔药。攻击动画在第 3、4、5 帧都在判定窗口内,如果每帧都检测,一次出招会调三次 TakeDamage。正确做法是首次命中就上锁,EnterState 进 ATTACK 时解锁。

攻击盒和受击盒分开算,也是这套代码里比较讲究的一点:

// Fighter.cpp —— 攻击盒在身体前方,受击盒比贴图窄一圈 RECT Fighter::HitBox() const { RECT r; int w = m_hitW, h = m_hitH; if (m_facingRight) { r.left = m_x + m_cellW - 8; r.right = r.left + w; } else { r.right = m_x + 8; r.left = r.right - w; } r.top = m_y + (m_cellH - h) / 2; r.bottom = r.top + h; return r; } RECT Fighter::BodyBox() const { // 左右各收 12 像素:贴图边缘有留白,判定跟着贴图走会显得「隔空打人」 RECT r = { m_x + 12, m_y + 8, m_x + m_cellW - 12, m_y + m_cellH }; return r; }

受击盒收窄这 12 个像素不是随便写的。精灵图为了画挥剑动作,左右都留了空白,如果受击盒等于贴图矩形,视觉上剑尖离人还有一截就已经判定命中,玩家会觉得这游戏判定太松。反过来如果收得太狠,又会出现「明明贴脸了打不中」。我一般先用 12 起步,跑起来看慢放再微调,这个值没有标准答案,只能靠看。

4.2 伤害结算与硬直:谁先扣血、谁进 HURT

命中的结果不只是扣血,还要有反馈,否则打起来像在戳纸。

// Fighter.cpp —— 扣血、进硬直、给击退,血空了直接进死亡 void Fighter::TakeDamage(int dmg, int knockDir) { m_hp -= dmg; if (m_hp <= 0) { m_hp = 0; EnterState(ST_DEAD); // 死亡状态不响应输入 return; } m_vx = knockDir * m_knockback; // 击退速度,HURT 状态里逐帧衰减 EnterState(ST_HURT); }

这里有个顺序问题:必须先把 m_vx 赋值,再调 EnterState,因为 EnterState 里有 m_vx = 0 的清零逻辑。反过来的话击退永远不生效,角色挨打只是原地抖一下。这类「初始化和清空打架」的 bug 在老代码里特别常见,表现是功能完全不生效,但代码看起来每一步都写了。

回合和胜负的处理放在 Battle 里,不塞进 Fighter:血量为 0 时广播一个 KO 事件,Battle 收到后冻结双方输入、播放 KO 文字、计时 3 秒,然后调用双方的 Reset 回到出生点。把「一局结束」的逻辑和「一个角色怎么死」的逻辑分开,后面要加三局两胜、加时间限制都只改 Battle 一个文件。

4.3 资源组织:位图格式、透明色、命名规则

资源这块没什么技术含量,但坑特别多,先立三条规矩能省下大量时间。

位图统一 24 位真彩,不用 32 位带 alpha。因为 TransparentBlt 只认一个透明色,32 位图的 alpha 通道它不处理,混着放会出现半透明边缘变成黑边。透明色统一定为品红 RGB(255,0,255),不要在不同文件里换颜色,否则加载代码里要维护一张颜色表。

命名规则跟着 ini 字段走:player1_idle.bmp、player1_walk.bmp、player1_attack.bmp,加载函数拼字符串就能找到文件,不用在代码里写死一长串路径。文件名里不要出现空格和中文,老工程用的多字节字符集,中文路径在部分系统上会读不到文件,这个血泪经验后面还会提到。

音效用 wav,不用 mp3。PlaySound 只能播 wav,而且同一时刻只能播一个:打击音一响,背景音乐就断,这是这套代码的天然限制。要在不换音频库的前提下缓解,常见的做法是把背景音乐关掉、只留打击音;要真的混音,得引入 DirectSound 或者自己写个简单的混音缓冲,那是二次开发阶段的事,不是跑通阶段该纠结的。

4.4 打击感的最小实现:三帧停顿加一个音效

打击感这个词听起来玄学,但最小实现只有两件事:命中瞬间让对方播放受击动画,同时画面停一下。

// Battle.cpp —— 命中后冻结 3 帧,配合受击音效,打击感立刻不一样 if (CheckHit(p1, p2)) { m_hitStopFrames = 3; // 全局冻结帧数 PlaySound(L"res/hit.wav", NULL, SND_FILENAME | SND_ASYNC); }

m_hitStopFrames 在主循环里判断,大于 0 就跳过双方 Update,只跑动画绘制,每帧减一。这三帧不长,但视觉上是「顿一下」,玩家立刻能感觉到打实了。不加这个停顿,纯靠动画播放,打起来就是软的——这是很多自制格斗游戏和商业作品最直观的差距之一。

5. 编译、链接、跑不起来的排查清单

老 VC++ 工程的坑,一半在编译期,一半在运行期,还有一小半在手感上。下面几条是我在这个包和同类工程上真踩过的,按现象、原因、解决三段写清楚。

5.1 编译期与链接期:字符集、库、入口点

现象:用较新版本的 IDE 打开 .dsw,提示工程需要转换,转完之后一片 C4996、C2664 报错,原来能编的代码编不过了。原因:老工程默认用多字节字符集(MBCS),新 IDE 的默认工程配置是 Unicode,TCHAR 展开成 wchar_t,原本传 char* 的地方全部类型不匹配。解决:在工程属性的字符集选项里改回「使用多字节字符集」,别去逐个函数加转换宏,那是个无底洞。顺带把_CRT_SECURE_NO_WARNINGS加进预处理器定义,省掉一堆 strcpy 的安全警告。

现象:编译通过,链接报 LNK2019,未解析的外部符号 _TransparentBlt@44。原因:TransparentBlt 不在默认链接的库里面,它在 msimg32.lib 里。解决:链接器附加依赖项加 msimg32.lib,或者在 Renderer.cpp 顶部加一行#pragma comment(lib, "msimg32.lib"),后者更省事,跟着源文件走,换工程也不会丢。

5.2 运行期:黑屏、资源找不到、只有一个人能动

现象:双击 exe 或者 F5 启动,窗口出来了,一片纯色,进程还在跑,什么都不显示。原因有两个常见来源:一是资源用了相对路径 "res/xxx.bmp",而调试器的工作目录是工程目录不是输出目录,LoadImage 返回 NULL 但代码没检查;二是双缓冲的 m_memBmp 创建失败,尺寸传了 0。解决:用 GetModuleFileName 取 exe 路径再拼资源目录,或者把所有资源编进 .rc 资源文件里用 LoadBitmap 直接取;同时给每次加载加断言,加载失败就在启动阶段崩掉,而不是带病运行到黑屏。

现象:1P 一切正常,2P 只能移动,方向键和数字键按了没反应,或者反过来只有方向键能用。原因:对话框或者某些控件风格下,方向键被系统的焦点导航吞掉了,WM_KEYDOWN 根本到不了你的窗口过程。解决:在窗口过程里处理 WM_GETDLGCODE 返回 DLGC_WANTALLKEYS,或者在 PreTranslateMessage 里把不需要的消息直接拦下返回 TRUE。另外确认 2P 的攻击键用的是主键盘数字还是小键盘数字,VK_NUMPAD1 和 '1' 是两个不同的码,写错了就是按了没反应。

5.3 手感类:判定玄学与按键粘滞

现象:同一个位置、同样的距离,有时候打得中,有时候打不中,玩家反馈「这游戏判定有问题」。原因:逻辑更新和渲染更新共用一个不固定步长的循环,掉一帧的时候攻击判定的第 3 帧被整帧跳过,判定窗口直接消失。表现就是玄学。解决:按 2.2 那样改成固定步长,逻辑帧和渲染帧解耦;判断攻击窗口用逻辑帧号,不用时间戳。

现象:按住方向键再按攻击,人一边往出走一边挥拳,或者出招后角色还在往前滑一段。原因:状态迁移时没有清理速度,WALK 状态里直接累加的 m_x 残留在 ATTACK 状态里继续生效。解决:EnterState 里统一把速度清零,位移一律由各状态的动画数据驱动,不要在任何 case 里直接改 m_x 之外还留着旧速度。

6. 二次开发:加一个角色要动哪几处,怎么验证没改坏

拿到这份代码,最实用的改造就是加第三、第四个角色,或者把一个角色改成完全不同的手感。路径其实很短,四步走完。

第一步,加资源。在 res 下放一套新角色的位图,命名按 player3_idle.bmp、player3_walk.bmp 这个规律来,格子和现有角色保持一致(不然要改 cell_w/cell_h,换算会跟着变)。第二步,加配置。在 data/fighters.ini 里复制一段 [player1] 改成 [player3],把速度、伤害、判定窗口按新角色调。第三步,注册。在角色初始化表里加一项,把 player3 的配置文件和资源路径挂上去。第四步,接线。把 2P 的按键映射切到新角色,或者加一个选人界面——后者已经超出这份代码的范围,属于从零新增模块了。

改完不要急着调手感,先跑三项验证,这三项能挡住九成的低级错误:

验证项做法说明
站立循环不按键观察 10 秒动画播完应回到第 0 帧,不闪、不卡在最后一帧
边界行走一直往左走角色停在场景左边缘,不穿墙、不抖动
单次命中近距离打一拳对面只掉一次血,进硬直,击退方向朝背离攻击者那一侧

三项都过了再开始调 attack_hit_start 和 attack_hit_end。我一般先把窗口开到最宽(比如 2 到 6),确认能打中,再一点点收窄到 3 到 5,这样能区分「判定位置错了」和「窗口太窄」两种问题。反过来做的话,窗口窄的时候打不中,你分不清是位置没对上还是帧数不够,只能在两个变量之间来回猜,非常浪费时间。

还有一个容易被忽略的改造点:帧率。这份代码的位移是按「每帧多少像素」写的,如果你把 frameMs 从 16 改成 8,所有人都会变成两倍速。正确的改法是在 Update 里引入 dt 系数,把 m_walkSpeed 之类的值统一乘上 frameMs/16,这样帧率变化时手感才不变。这一步改动看着小,但要动所有涉及位移的地方,建议改完拿走路速度当基准测一遍:走完整个场景宽度的时间应该和改之前一致。

最后说个我自己的教训。早些年我改这类老工程,习惯改完直接编译跑,看两眼觉得没问题就收工,结果过两天加新角色时发现上次改的受击盒偏移是全局的,新角色比老角色矮,判定直接错位,回头查了半天。从那以后我每次动这类判定和坐标相关的代码,都强制要求自己开一帧一帧慢放走一遍完整攻防,确认命中位置和视觉对齐才提交。这个习惯看着笨,但省下的返工时间远比慢放那几分钟多。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进