首页 / 资讯中心 / 文章详情

Unity复刻英雄联盟:MOBA核心系统从零构建实战

Unity复刻英雄联盟:MOBA核心系统从零构建实战 ★ FEATURED ARTICLE
1. 从零构建一个MOBA为什么我选择用Unity复刻英雄联盟的核心系统聊到MOBA游戏开发很多人第一反应是“这玩意儿一个人做不了”。确实英雄联盟这种体量的产品背后是几百人的团队、数年的迭代和上亿的预算。但如果你把目标从“做一个完整的商业游戏”调整为“吃透MOBA的核心技术骨架”事情就完全不一样了。我过去两年断断续续用Unity搭了一套可运行的MOBA原型从帧同步网络层、技能编辑器、小地图寻路到Protobuf协议序列化踩了无数坑也攒了一堆可以直接抄作业的方案。这篇文章就是把这套东西完整拆开讲清楚每个模块为什么这么设计、具体怎么实现、以及哪些地方你千万别学我走弯路。先明确这套教程适合谁有Unity基础知道GameObject、Prefab、C#脚本怎么写想往游戏后端或客户端架构方向深入的人或者你正在准备游戏开发岗位的面试需要一套能讲清楚“帧同步和状态同步区别”“技能系统怎么解耦”“寻路怎么优化”的实战项目。如果你完全没碰过Unity建议先花一周把官方Roll-a-Ball教程过一遍再回来。整套原型我用的Unity 2022.3 LTS版本渲染管线是URP网络层基于Protobuf自定义协议寻路用A*加导航网格混合方案。最终跑起来的效果是两个客户端能同步移动、释放技能、小兵自动寻路推塔、伤害计算和状态同步基本正确。代码量大概一万两千行不算多但每个模块都值得展开讲。为什么选Unity而不是Unreal这是个老生常谈的问题。Unreal的渲染和物理确实强蓝图系统对策划友好但MOBA这类游戏的核心难点不在画质而在网络同步和逻辑帧的确定性。Unity的C#生态在服务器端有天然优势——你可以用同一套语言写客户端和逻辑服Protobuf的C#支持也成熟。另外Unity的Job System和Burst Compiler在批量计算小兵AI和碰撞检测时性能提升明显我实测过同样一千个单位寻路Job化之后帧率从22帧拉到58帧。当然Unreal的GASGameplay Ability System确实香但那个系统太重自己从头搭一套轻量级的技能框架反而更可控。注意如果你打算做的是单机MOBA或者局域网联机状态同步足够用但如果你想模拟英雄联盟那种“服务器权威帧同步回放”的体验必须从第一天就把逻辑帧和渲染帧分离。我一开始图省事把移动逻辑写在Update里后来重构花了整整三周。2. 核心架构拆解逻辑帧、渲染帧与网络层的三角关系2.1 为什么MOBA必须做逻辑帧与渲染帧分离先讲一个最容易被新手忽略但最致命的问题帧同步游戏里所有客户端的逻辑计算必须完全一致。这意味着你不能在逻辑代码里用Time.deltaTime不能依赖浮点数的跨平台一致性更不能把渲染相关的插值混进逻辑层。我的做法是定义一个固定的逻辑帧率比如30帧/秒所有游戏逻辑移动、技能冷却、伤害计算、AI决策都在FixedUpdate里以固定步长执行而渲染层用Update做插值平滑。具体实现上我写了一个LogicTick管理器public class LogicTick : MonoBehaviour { public static readonly float TickRate 1f / 30f; private float accumulator 0f; private int currentFrame 0; void Update() { accumulator Time.deltaTime; while (accumulator TickRate) { currentFrame; LogicWorld.Instance.Step(currentFrame); accumulator - TickRate; } float alpha accumulator / TickRate; RenderInterpolator.Instance.Interpolate(alpha); } }LogicWorld.Step里跑的是纯逻辑不碰任何Unity的Transform组件。每个单位在逻辑层有一个LogicTransform结构体存位置和朝向渲染层的GameObject只是根据逻辑位置做插值显示。这样做的好处是回放系统天然支持记录每帧的输入指令即可断线重连只需要服务器发一份完整状态快照而且逻辑帧可以加速跑比如服务器验证时用10倍速重演。2.2 Protobuf在MOBA协议设计中的实战用法网络协议这块我试过三种方案JSON、MessagePack和Protobuf。JSON调试方便但体积大一个移动指令要80字节MessagePack压缩率不错但C#的反射序列化有GC压力最后选了Protobuf同样的移动指令压到14字节而且生成的C#代码是纯IL没有反射开销。协议定义我分成三层Cmd客户端上行指令、Snapshot服务器下行状态快照、Event广播事件如击杀、推塔。用.proto文件描述message MoveCmd { int32 playerId 1; float targetX 2; float targetZ 3; int32 frame 4; } message UnitSnapshot { int32 unitId 1; float posX 2; float posZ 3; int32 hp 4; int32 state 5; } message FrameSnapshot { int32 frame 1; repeated UnitSnapshot units 2; }这里有个关键决策移动指令只发目标点不发路径。因为路径计算在客户端和服务器端用同一套A算法只要输入相同、地图数据相同算出来的路径必然一致。这比每帧同步位置省了90%的带宽。但前提是你的A实现必须确定性——不能用List.Sort这种不稳定排序我后来换成了自己写的二叉堆。2.3 状态同步与帧同步的混合方案纯帧同步的问题在于一旦某个客户端算错了比如浮点数精度差异后面所有帧都会雪崩。纯状态同步又太吃带宽英雄联盟那种十个英雄加几十个小兵每秒同步一次位置就是几KB。我的方案是混合移动和技能释放走帧同步只同步输入指令但关键状态血量、buff、装备走状态同步服务器定期广播快照。具体来说服务器每5帧发一次FrameSnapshot包含所有单位的血量、蓝量、buff状态。客户端收到快照后跟本地逻辑计算结果做对比如果偏差超过阈值比如位置差超过0.5米就强制拉回。这个阈值不能太小否则网络抖动时角色会鬼畜也不能太大否则外挂可以钻空子。我实测下来0.3到0.5米比较合适具体看你的移动速度。实操心得Protobuf的repeated字段在C#里生成的是RepeatedFieldT遍历时用for循环比foreach快30%左右因为foreach会走IEnumerator接口。另外序列化时记得复用CodedOutputStream实例每次new一个会产生大量GC。3. 技能系统与战斗逻辑从数据驱动到帧同步执行3.1 技能配置表的设计与热重载MOBA的技能系统最忌讳硬编码。我见过有人把每个技能写成一个if-else分支加一个新英雄就要改核心代码。正确的做法是数据驱动技能的所有参数冷却、耗蓝、伤害、范围、弹道速度都放在ScriptableObject或者Excel导出的二进制表里。我的技能配置结构大概长这样[CreateAssetMenu(fileName SkillConfig, menuName MOBA/Skill)] public class SkillConfig : ScriptableObject { public int skillId; public string skillName; public float cooldown; public float manaCost; public SkillType type; // 指向性、非指向性、AOE、增益 public float range; public float damageBase; public float damageADScale; public float damageAPScale; public string prefabPath; public string logicScript; // 技能逻辑脚本名 }logicScript字段是关键——它存的是技能逻辑类的名字运行时用反射创建实例。这样加新技能只需要写一个新的SkillLogic子类不用改任何现有代码。但反射有性能开销所以我在加载时会把所有技能逻辑类缓存到一个Dictionarystring, Type里运行时只查字典。热重载方面我用了一个简单的文件监听当Excel配置表被修改并导出为二进制后FileSystemWatcher触发重新加载。注意要在逻辑帧的间隙做重载不能在Step执行到一半时换配置否则会出现“技能放了一半参数变了”的诡异bug。3.2 技能释放的帧同步执行流程一个技能从按下按键到实际生效中间要经过输入采集→指令封装→网络发送→服务器验证→广播→客户端执行。在帧同步框架下每个环节都要绑定帧号。我的流程是这样的玩家按下Q键客户端记录InputCommand{frame当前逻辑帧输入延迟补偿, skillId1001, targetPos鼠标位置}指令通过Protobuf序列化后发给服务器服务器收到后先做合法性校验冷却好了没、蓝够不够、距离够不够校验通过后把指令塞进对应帧的指令队列服务器在逻辑帧N执行时从队列取出该帧所有指令按玩家ID排序后依次执行执行结果伤害数字、buff添加、特效触发作为Event广播给所有客户端客户端在逻辑帧N收到Event后播放对应的表现层效果这里有个坑服务器校验时用的冷却时间必须和客户端完全一致。我一开始客户端用Time.time算冷却服务器用逻辑帧算结果客户端显示冷却好了但服务器说没好。后来统一改成逻辑帧计数冷却结束帧号存在单位状态里两边都查这个帧号。3.3 伤害计算与属性系统的解耦伤害计算看起来简单其实很容易写成一团乱麻。我的做法是把属性系统和伤害计算完全分开属性系统只负责存基础值和计算最终值伤害计算只负责拿最终值做公式。属性系统用了一个AttributeSet结构public struct AttributeSet { public float baseAD; public float bonusAD; public float baseAP; public float bonusAP; public float armor; public float magicResist; public float attackSpeed; // ... 其他属性 public float FinalAD baseAD bonusAD; public float FinalAP baseAP bonusAP; }伤害公式我参考了英雄联盟的简化版物理伤害 攻击力 × (100 / (100 护甲))。这个公式的好处是护甲的收益递减不会出现堆到1000护甲就无敌的情况。魔法伤害同理用魔抗计算。关键点所有伤害计算必须在逻辑帧内完成而且不能用Mathf里的浮点函数比如Mathf.Pow因为不同平台的浮点实现可能有微小差异。我后来换成了定点数库用long存放大1000倍后的整数所有运算都是整数运算。这样跨平台一致性有保证代价是精度损失0.001的误差但对MOBA来说完全够用。常见问题技能指示器那个箭头或者圆圈的显示和实际判定范围不一致。原因是表现层用了Physics.OverlapSphere做检测而逻辑层用的是自己算的距离。解决方法是逻辑层算完后把结果命中/未命中作为Event发给表现层表现层只负责画特效不做任何判定。4. 寻路与AI小兵、野怪和防御塔的行为树实现4.1 A*寻路在MOBA地图上的优化实践MOBA地图不大但单位多。英雄联盟一局游戏同时存在的小兵、野怪、英雄加起来大概60到80个单位每个单位都要寻路。如果用Unity自带的NavMesh每个单位一个Agent性能直接爆炸。我的方案是自己实现A*配合分层寻路和路径缓存。地图我切成1米×1米的格子整个召唤师峡谷大概200×200格。A*的启发函数用曼哈顿距离乘以1.001稍微放大一点避免最优路径被跳过。关键优化有这几个分层寻路先在大格子8×8米上跑粗粒度A*找到大致方向后再在小格子上细化。这样搜索节点数从40000降到625速度提升几十倍。路径缓存相同起点和终点的寻路请求直接查缓存。小兵从基地到线上路径基本固定缓存命中率很高。异步寻路寻路放到Job System里跑主线程只负责取结果。我用了Unity的NativeArray和IJobParallelFor一次能并行算32个单位的路径。具体代码结构public struct PathfindingJob : IJobParallelFor { [ReadOnly] public NativeArrayfloat2 gridCosts; [ReadOnly] public NativeArrayPathRequest requests; public NativeArrayPathResult results; public void Execute(int index) { var request requests[index]; var result AStar.FindPath(gridCosts, request.start, request.end); results[index] result; } }注意NativeArray要在Job完成后Dispose否则内存泄漏。我一开始忘了释放跑了十分钟后游戏直接崩了。4.2 小兵行为树与状态机设计小兵的行为看起来简单沿着路走、看到敌人就打、没敌人继续走但写起来一堆边界情况敌方小兵死了要重新选目标、被嘲讽了要强制攻击、走到塔下要优先打塔。我用行为树来管理每个小兵有一个BehaviorTree实例节点包括Sequence、Selector、Condition和Action。核心行为树结构Selector优先级从高到低Sequence被控制→ 执行控制效果Sequence有敌方单位在攻击范围内→ 攻击Sequence有敌方单位在视野内→ 移动过去Action沿路径前进行为树的节点我用了对象池避免每帧new节点产生GC。每个小兵的行为树实例大概占200字节80个小兵就是16KB完全可以接受。状态机方面小兵有Idle、Moving、Attacking、Dead四个状态。状态切换的条件写在行为树的Condition节点里。这里有个细节小兵的攻击前摇和后摇必须和逻辑帧对齐。比如攻击前摇0.25秒在30帧逻辑帧率下就是7.5帧我取整成8帧。前摇结束的那一帧才判定伤害而不是动画播到某个时间点。4.3 防御塔的仇恨机制与目标选择防御塔的AI比小兵复杂因为涉及仇恨优先级。英雄联盟的规则是优先攻击正在攻击己方英雄的敌方英雄其次攻击小兵最后攻击野怪。我用了一个简单的优先级队列public class TowerAI : MonoBehaviour { private ListUnit enemiesInRange new ListUnit(); Unit SelectTarget() { // 优先级1攻击己方英雄的敌方英雄 var heroAttackingAlly enemiesInRange .Where(u u.type UnitType.Hero u.currentTarget?.type UnitType.Hero) .OrderByDescending(u u.threatLevel) .FirstOrDefault(); if (heroAttackingAlly ! null) return heroAttackingAlly; // 优先级2小兵 var minion enemiesInRange .Where(u u.type UnitType.Minion) .OrderBy(u u.distanceToTower) .FirstOrDefault(); if (minion ! null) return minion; // 优先级3野怪 return enemiesInRange.FirstOrDefault(u u.type UnitType.Monster); } }threatLevel是个动态值英雄攻击己方英雄时100攻击小兵时10每秒衰减5。这样坦克英雄冲塔时塔会优先打坦克而不是后面的ADC。避坑技巧防御塔的仇恨切换不能太频繁否则会出现“塔打一下ADC又转去打辅助”的鬼畜现象。我加了一个2秒的仇恨锁定时间锁定期间即使有更高优先级的目标也不切换。5. 渲染与表现层让逻辑帧的数据看起来丝滑5.1 逻辑位置到渲染位置的插值方案逻辑帧30帧/秒渲染帧可能60帧甚至120帧。如果不做插值角色移动会一顿一顿的。我的插值方案是渲染层保存上一逻辑帧和当前逻辑帧的位置根据alpha值做线性插值。public class RenderInterpolator : MonoBehaviour { private Dictionaryint, Transform unitTransforms; private Dictionaryint, Vector3 prevPositions; private Dictionaryint, Vector3 currPositions; public void Interpolate(float alpha) { foreach (var kvp in unitTransforms) { int unitId kvp.Key; if (prevPositions.ContainsKey(unitId) currPositions.ContainsKey(unitId)) { Vector3 pos Vector3.Lerp(prevPositions[unitId], currPositions[unitId], alpha); kvp.Value.position pos; } } } }注意朝向也要插值但不能用Vector3.Lerp要用Quaternion.Slerp。另外如果两个逻辑帧之间距离太远比如闪现插值会看起来像瞬移这时候要检测距离超过阈值就直接跳过去不做插值。5.2 技能特效与逻辑事件的绑定表现层的特效不能自己触发必须由逻辑层的Event驱动。我定义了一个EventBus逻辑层执行完技能后发一个SkillCastEvent表现层订阅这个事件后播放对应特效。public struct SkillCastEvent { public int casterId; public int skillId; public Vector3 targetPos; public int frame; } // 表现层订阅 EventBus.SubscribeSkillCastEvent(OnSkillCast); void OnSkillCast(SkillCastEvent evt) { var config SkillConfigManager.Get(evt.skillId); var prefab Resources.LoadGameObject(config.prefabPath); var effect Instantiate(prefab, evt.targetPos, Quaternion.identity); Destroy(effect, 3f); }这里有个性能问题Instantiate和Destroy很吃性能尤其是团战时一秒几十个特效。我后来改成了对象池每种特效预实例化20个循环使用。对象池的Get和Release都在主线程做但Release时要把特效重置到初始状态粒子系统重启、动画归零。5.3 小地图与战争迷雾的实现思路小地图看起来简单其实涉及视野计算。我的方案是每个单位有一个VisionRange服务器每逻辑帧计算一次所有单位的可见性把结果打包成VisibilitySnapshot发给客户端。客户端根据这个快照决定哪些单位在小地图上显示、哪些在主画面里隐藏。视野计算用了一个简单的网格标记法把地图切成小格子每个单位把自己视野范围内的格子标记为可见。多个单位的视野取并集。这个计算在服务器端做因为客户端做的话容易被外挂篡改。小地图的渲染用了一个RenderTexture主摄像机渲染一个俯视图到RenderTexture小地图UI直接显示这个Texture。但这样会把战争迷雾也渲染进去所以我在渲染前先把不可见的单位隐藏渲染完再恢复。这个操作每帧做一次开销不大但要注意别在渲染过程中改GameObject的active状态会报错。实操心得战争迷雾的渐变边缘用了一张噪声图做遮罩比纯色遮罩好看很多。噪声图在Photoshop里生成导入Unity后设置成Repeat模式UV根据时间偏移看起来就像云雾在飘。6. 常见问题与排查实录那些让我熬夜的Bug6.1 帧同步不同步的排查思路帧同步最怕的就是不同步。我遇到过一次两个客户端跑了十分钟后一个客户端的小兵已经推到高地了另一个客户端的小兵还在二塔。排查过程如下先确认输入指令是否一致把两个客户端的指令日志导出来对比发现第1523帧有一个移动指令的目标点差了0.001。追查这个0.001的来源发现是鼠标点击时用了Camera.ScreenToWorldPoint而两个客户端的摄像机位置有微小差异因为插值alpha不同。解决方案鼠标点击的坐标不在客户端计算而是把屏幕坐标发给服务器服务器统一转成世界坐标后再广播。这个坑让我明白一个道理帧同步游戏里任何依赖客户端本地状态的计算都可能成为不同步的源头。后来我把所有“需要一致”的计算都挪到了服务器端客户端只负责发原始输入和收结果。6.2 Protobuf序列化性能问题的优化Protobuf虽然快但用不对也会卡。我遇到的问题是每帧序列化FrameSnapshot时repeated UnitSnapshot字段会不断扩容产生GC。解决方案是复用FrameSnapshot实例每次序列化前先Clear再填充。private FrameSnapshot cachedSnapshot new FrameSnapshot(); byte[] SerializeSnapshot(ListUnit units) { cachedSnapshot.Clear(); foreach (var unit in units) { cachedSnapshot.Units.Add(new UnitSnapshot { ... }); } using (var stream new MemoryStream()) { cachedSnapshot.WriteTo(stream); return stream.ToArray(); } }另外MemoryStream也可以复用设置Position 0和SetLength(0)就行。这样优化后序列化耗时从每帧2.3ms降到0.4ms。6.3 Unity渲染相关的典型问题速查问题现象可能原因解决方案阴影闪烁阴影距离设置过小调大Shadow Distance或改用级联阴影模型包围盒异常导入时Scale不是1在Import Settings里勾选Reset或手动改Scale小地图显示黑块RenderTexture格式不对改成ARGB32关闭Mip Maps技能特效不显示层级不对检查特效的Layer是否在相机Culling Mask里角色移动卡顿逻辑帧和渲染帧没分离按本文2.1节做插值微信小游戏视频播放失败视频格式不支持转成H.264用VideoPlayer的URL模式6.4 性能优化的几个关键数据我做过一轮完整的性能分析数据如下逻辑帧耗时优化前8.2ms优化后2.1ms主要优化了A*和伤害计算渲染帧耗时优化前14ms优化后6ms主要优化了特效对象池和UI合批网络带宽优化前每客户端上行12KB/s下行45KB/s优化后上行3KB/s下行18KB/s内存占用优化前1.2GB优化后480MB主要优化了Texture和Audio的加载策略优化手段按收益排序对象池 逻辑帧分离 协议压缩 资源异步加载 Job System并行。最后分享一个小技巧Unity的Profiler里有个Deep Profile模式能精确到每个函数的耗时。但开了之后性能会掉一半所以只在定位问题时开定位完就关。另外Profiler.BeginSample和EndSample可以手动埋点比Deep Profile轻量很多。这套原型我还在持续迭代最近在加回放系统和观战模式。回放的核心就是记录每帧的输入指令然后以任意速度重演。观战模式复杂一点需要服务器额外发一份“观战者视角”的快照但逻辑层完全复用。如果你也在做类似的东西建议先把逻辑帧和渲染帧的分离做扎实后面加任何功能都会轻松很多。
阅读完成 · 觉得有帮助?
咨询建站