十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

聊聊武器系统:为什么我们最后还是选了数据驱动 + 状态机

聊聊武器系统:为什么我们最后还是选了数据驱动 + 状态机 写这篇的起因是最近带新人他接手武器模块第一天就问我“这代码怎么全是 if-else 套 switch一个开火逻辑写了三百行”我看了眼提交记录——好家伙那是三年前项目刚立项时留下的祖传代码。当时赶 demo怎么快怎么来结果一路裸奔到现在。这次正好借重构的机会把我们踩过的坑和最后的方案捋一遍。先说说当初是怎么烂掉的最早的武器就一个Gun类开火、换弹、切枪全塞里面。刚开始只有一把手枪挺好。后来加了步枪有连发。行吧加个isAuto字段判断一下。再后来策划说要点射三连发那种。又加个burstCount。然后霰弹枪来了一次打八发弹丸。狙击枪要开镜开镜还要改射速和后坐力。喷子换弹是一发一发压进去的中途可以打断……到这个阶段Update里面已经是这个鬼样子voidUpdate(){if(isReloading){reloadTimerTime.deltaTime;if(weaponTypeWeaponType.Shotgun){// 逐发换弹的特殊处理if(reloadTimersingleShellTimecanInterruptInput.GetButton(Fire)){// 打断换弹...}}elseif(reloadTimerreloadTime){// 普通换弹完成}return;// 换弹时不能干别的}if(Input.GetButton(Fire)!isReloadingcurrentAmmo0){if(isAuto){...}elseif(isBurstburstShotCountburstCount){...}elseif(!hasFiredThisClick){...}}// 还有开镜、切枪、检查弹药……}每加一把有点特殊行为的枪就得往这坨里塞新的分支。改一个 bug 经常带出三个新的。最要命的是——没人敢动它。测试提了 bug 都得排队等我有空。这就是典型的状态和行为揉在一起的下场。武器此刻能干什么、不能干什么全靠一堆布尔变量的组合去判断而这些组合会随枪的种类爆炸式增长。拆解数据是数据行为是行为重构第一步我逼自己回答一个问题一把枪到底由什么构成想清楚之后发现它其实是两个正交的维度第一个维度是它是什么——伤害多少、射速多快、弹匣多大、什么开火模式、后坐力曲线长啥样。这些是静态属性一把 AK 和一把 M4 的区别本质上就是这堆数字不一样。第二个维度是它现在在干嘛——是待机、正在开火、正在换弹、还是弹药打空了。这些是动态行为任何一把枪都会经历这些状态只是具体参数不同。想通这层方案自然就浮出来了静态属性 →数据驱动全部丢到配置里动态行为 →状态机管好状态之间怎么流转数据这块别想太多也别想太少我见过两种极端。一种是把所有东西写死在代码里就是我们最早那样。另一种是过度工程搞一个能配置一切的万能表格字段几百个策划打开表都晕。我们的原则是凡是策划会反复调的、和数值/手感相关的全部进数据凡是涉及行为逻辑的留在代码里。在 Unity 里我们用ScriptableObject做武器配置。为什么不用纯 JSON/Excel因为 SO 能直接在 Inspector 里拖资源引用枪口特效、音效、动画策划改数值和美术挂资源可以在同一个地方完成非常顺手。当然真正的数值大表还是从 Excel 导SO 只是运行时的载体。[CreateAssetMenu(menuNameWeapon/Config)]publicclassWeaponConfig:ScriptableObject{[Header(身份)]publicstringweaponId;publicWeaponTypetype;[Header(数值)]publicfloatdamage25f;publicfloatfireRate0.1f;// 两发之间的最小间隔publicFireModefireMode;publicintburstCount3;// 点射数仅 Burst 模式用[Header(弹药)]publicintmagazineSize30;publicfloatreloadTime2.2f;[Header(后坐力)]publicAnimationCurverecoilVertical;// 用曲线比单个数值真实publicAnimationCurverecoilHorizontal;[Header(表现)]publicGameObjectmuzzleFlash;publicAudioClipfireSFX;}publicenumFireMode{Single,Auto,Burst}一个小细节后坐力我们用的是AnimationCurve而不是单一数值。因为真实的枪前几发跳得低、连射久了往上飘。策划直接在编辑器里拉曲线比反复试数字舒服太多。能让策划自己调的就别让他来找程序。状态机核心是谁负责状态切换状态机的写法网上一抓一大把我不想复述。这里只讲几个我们真正踩过坑的点。状态基类别把它设计得太胖一开始我给状态基类加了七八个虚方法OnEnter、OnExit、OnUpdate、OnFixedUpdate、CanTransitionTo、OnAnimationEvent…… 结果大部分状态只用到两三个剩下的全是空实现看着糟心。最后收敛成三个核心方法就够了publicabstractclassWeaponState{protectedreadonlyWeaponweapon;protectedWeaponConfigConfigweapon.Config;protectedWeaponState(Weaponweapon)this.weaponweapon;publicvirtualvoidEnter(){}publicvirtualvoidTick(floatdt){}publicvirtualvoidExit(){}}输入我没有单独抽HandleInput而是让状态在Tick里自己去读weapon.Input。少一层抽象代码反而更直观。抽象是有成本的不要为了看起来专业而抽象。状态切换的权力收归状态自己这是最关键的一点。谁来决定从开火切到换弹我们的答案是当前状态自己决定下一个状态。而不是搞一个中央的转换表去管所有规则。理由很简单开火状态最清楚打空了该去哪、“松开鼠标该干嘛”这些逻辑就近处理比在外面维护一张全局转换表清晰得多。转换表那套在状态特别多、转换关系特别复杂的时候才有优势武器这点状态用不上硬套只会增加心智负担。看开火状态就明白了publicclassFiringState:WeaponState{privatefloat_cooldown;privateint_shotsFired;publicFiringState(Weaponw):base(w){}publicoverridevoidEnter(){_cooldown0f;_shotsFired0;TryFireOneShot();// 进状态立刻打第一发保证跟手}publicoverridevoidTick(floatdt){_cooldown-dt;switch(Config.fireMode){caseFireMode.Single:// 单发打完就回待机等下次点击重新进 Firingweapon.SwitchStateIdleState();break;caseFireMode.Auto:if(!weapon.Input.HoldingFire)weapon.SwitchStateIdleState();elseif(_cooldown0f)TryFireOneShot();break;caseFireMode.Burst:if(_shotsFiredConfig.burstCount)weapon.SwitchStateIdleState();elseif(_cooldown0f)TryFireOneShot();break;}}privatevoidTryFireOneShot(){if(weapon.CurrentAmmo0){weapon.SwitchStateEmptyState();return;}_cooldownConfig.fireRate;_shotsFired;weapon.CurrentAmmo--;weapon.Shoot();// 射线检测、伤害结算weapon.PlayFireFX();// 特效音效参数都从 Config 拿weapon.AddRecoil(_shotsFired);// 把当前是第几发传进去用于查后坐力曲线}}注意AddRecoil把_shotsFired传进去了——这就是前面后坐力曲线的用武之地第几发对应曲线上不同的采样点。跟手的魔鬼细节上面Enter里我立刻打了第一发。这是被测试喷出来的经验。最早的版本是进状态后等一个fireRate才打第一发结果射击手感黏点一下有肉眼可见的延迟。后来改成进状态立即射击、之后再按fireRate节流手感立马就对了。这种东西写文档写不出来全靠实际拿手柄/鼠标去试。武器手感这事代码只是骨架最后半成的手感全在这些一两帧的细节里。把它们拼起来武器主类现在瘦得像根竹竿它只负责持有数据、维护弹药、提供各种能力给状态调用以及驱动状态机publicclassWeapon:MonoBehaviour{[SerializeField]privateWeaponConfigconfig;publicWeaponConfigConfigconfig;publicintCurrentAmmo{get;set;}publicintReserveAmmo{get;set;}publicWeaponInputInput{get;privateset;}privateWeaponState_state;privatereadonlyDictionaryType,WeaponState_statesnew();voidAwake(){RegisterStates();CurrentAmmoconfig.magazineSize;SwitchStateIdleState();}voidUpdate(){InputReadInput();_state.Tick(Time.deltaTime);}publicvoidSwitchStateT()whereT:WeaponState{_state?.Exit();_state_states[typeof(T)];_state.Enter();}// 下面这些是状态会调用的能力publicvoidShoot(){/* 射线 / 弹道 */}publicvoidPlayFireFX(){/* 用 config 里的特效音效 */}publicvoidAddRecoil(intshotIndex){/* 采样 config 的后坐力曲线 */}publicboolCanReload()CurrentAmmoconfig.magazineSizeReserveAmmo0;publicvoidFinishReload(){intneedconfig.magazineSize-CurrentAmmo;intgotMathf.Min(need,ReserveAmmo);CurrentAmmogot;ReserveAmmo-got;}}新加一把枪做个WeaponConfig配好数值挂上。行为逻辑一行代码不用改。新加一种行为比如喷子的逐发换弹——写一个ShotgunReloadState只在这个状态里处理打断逻辑完全不污染其他枪。这就是拆开之后最爽的地方改动被隔离在很小的范围里。重构之后实际的收益聊点实在的不吹方案有多美。bug 定位快了。以前一个开火问题得在三百行里 debug现在直接看是哪个状态出的问题FiringState就那几十行。测试甚至学会了自己在报告里写应该是 Firing 状态的问题。策划不来烦我了。数值、射速、后坐力全在表里他们自己调。我一周至少省下小半天。加枪成本从两天降到两小时。前提是这把枪的行为没有特殊之处。有特殊行为的比如能量武器过热、蓄力枪就新写个状态也就半天。几个我想提醒你别踩的坑一、别一上来就上分层状态机。我知道你看过那种主状态 子状态的架构很酷。但如果你的武器状态就五六个扁平的状态机完全够用分层只会增加复杂度。等你真的遇到开镜/腰射这种正交状态组合导致状态爆炸时再上不迟。架构是长出来的不是一开始设计出来的。二、状态机管的是武器逻辑状态不是动画状态。这俩别混。动画有自己的 Animator 状态机。武器逻辑状态切换时去触发动画播放但两者是解耦的。我们早期图省事想用一套结果动画的过渡混合把逻辑判断搞得一团糟后来老实分开。三、表现层用事件解耦别在状态里直接调 UI。开火时子弹数变了UI 要刷新。别在FiringState里直接ammoText.text ...。抛个事件出去UI、音效、成就系统各自订阅publiceventActionint,intOnAmmoChanged;// (当前, 备弹)这样武器模块可以独立测试不依赖任何 UI。将来做无 UI 的机器人 AI 用同一套武器也不用改一行。四、网络同步是另一个话题但状态机帮了大忙。如果做联网状态机的状态本身就是很好的同步单位。哪个客户端处于什么状态、什么时候切换比同步一堆散乱的布尔变量清晰得多。不过预测和回滚是另一篇的内容了这里先按下不表。最后数据驱动 状态机不是什么高深理论本质就是把变化的数值和稳定的行为分开再把混在一起的行为按状态切干净。真正难的不是套用这个模式而是判断哪些该进数据、哪些该做成状态、什么时候不要过度设计。这些判断力说实话都是被烂代码折磨出来的。如果你现在的武器代码也是一坨 if-else别急着推倒重来。先从最痛的那把枪开始把开火逻辑抽成一个状态试试。跑通了再慢慢往外扩。重构这事一口吃不成胖子。下次有空聊聊配件系统怎么和这套结合——那玩意儿又是另一个坑。
返回列表