我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

从零构建MOBA:Unity/Unreal引擎选型与帧同步实战

从零构建MOBA:Unity/Unreal引擎选型与帧同步实战 1. 从零构建一个MOBA为什么我选择自己造轮子第一次冒出“自己写一个MOBA”的念头是在连续加班做完一个换皮项目之后。当时团队里几个人围在会议室白板前把《英雄联盟》的对局流程从头到尾拆了一遍选人、加载、出兵、对线、打野、团战、推塔、结算。拆完之后大家沉默了几秒——这套东西看起来每个模块都懂但真要从零搭起来坑比想象中多得多。MOBAMultiplayer Online Battle Arena多人在线战术竞技本质上是一个强实时、强同步、强状态的游戏类型。它不像单机RPG可以慢慢加载资源也不像卡牌游戏可以容忍几百毫秒的延迟。一次技能释放的判定窗口可能只有几十毫秒一次团战的同步数据量在高峰期能达到每秒几十KB。这就决定了它的技术选型和架构设计必须围绕“确定性”和“低延迟”来做文章。我写这篇东西的目的很直接把我在Unity和Unreal Engine两条技术路线上反复横跳、踩坑、回滚、重做的经验整理出来给同样想动手做MOBA的人一条相对清晰的路径。不管你是刚学完Unity基础想找个大项目练手的学生还是做了几年业务开发想转游戏后端的老兵这里面的架构思路和实操细节都能直接拿去用。核心关键词就几个英雄联盟式MOBA、Unity、Unreal Engine、Protobuf、帧同步与状态同步。我不会只讲概念每个模块都会落到具体的代码结构、参数配置和排查方法上。先说一个结论不要试图一开始就做“完整版”。我见过太多人上来就要做100个英雄、200件装备、排位赛系统结果三个月后连小兵寻路都没跑通。正确的做法是先做一个“最小可玩闭环”——两个英雄、一条兵线、一座防御塔、一个水晶能跑通完整的对局流程再往上叠内容。这个闭环里包含了MOBA所有的核心技术难点网络同步、技能系统、AI行为、战斗结算、UI状态管理。把这几个啃下来剩下的就是堆量和调优。2. 引擎选型与项目架构设计2.1 Unity还是Unreal别纠结看你的团队基因这个问题我被问过不下五十次。每次我的回答都一样看你团队最熟什么以及你的目标平台是什么。Unity的优势在于C#生态成熟、热更新方案多、移动端适配经验丰富、社区资源量大。如果你要做的是移动端MOBA或者微信小游戏方向的轻量级产品Unity几乎是默认选项。我实测下来Unity 2022 LTS版本在移动端的渲染性能和包体控制都比较稳配合Addressable资源系统做分包加载首包可以压到比较理想的水平。但Unity的坑在于它的网络层和物理层需要你自己搭引擎本身不提供帧同步或状态同步的完整方案你得从Socket开始写。Unreal Engine的优势在于渲染质量、动画系统和网络框架。UE自带的Replication系统对于状态同步类游戏非常友好角色移动、属性同步、RPC调用都有现成的机制。如果你要做的是PC端高品质MOBAUE的起点会高很多。但UE的C门槛和编译时间是个现实问题而且它的网络框架偏向FPS类游戏MOBA需要的“单位数量多、同步频率高、技能逻辑复杂”场景需要做不少定制。我的建议是移动端选UnityPC高品质选UE微信小游戏方向选Unity。如果你两个都不熟那就选Unity因为C#的学习曲线比C平缓而且Unity的教程和插件生态对新手更友好。2.2 客户端架构分层与解耦不管选哪个引擎客户端架构的核心思路是一样的表现层、逻辑层、数据层分离。表现层负责渲染、动画、特效、UI。逻辑层负责技能判定、移动计算、状态机。数据层负责存储英雄属性、装备数据、配置表。这三层之间通过事件或接口通信不能互相直接引用。我见过太多项目把技能逻辑写在MonoBehaviour的Update里结果后期想加个回放功能或者做帧同步发现根本拆不出来。具体到Unity项目结构我习惯这样分目录Assets/ Scripts/ Core/ // 框架层事件系统、对象池、定时器、日志 Data/ // 数据层配置表加载、运行时数据管理 Logic/ // 逻辑层战斗计算、技能系统、AI View/ // 表现层角色渲染、特效、UI Network/ // 网络层连接管理、消息编解码、同步 Resources/ Scenes/ Prefabs/核心原则是Logic层不引用UnityEngine。这样做的目的是让战斗逻辑可以脱离引擎运行方便做单元测试和服务器端复用。比如伤害计算公式、技能冷却计算、Buff叠加规则这些纯数学逻辑放在Logic层用C#原生代码写不依赖MonoBehaviour。View层只负责“把Logic层算出来的结果展示出来”。2.3 网络同步方案帧同步还是状态同步这是MOBA架构里最关键的决策没有之一。帧同步Lockstep的核心思路是所有客户端运行相同的逻辑代码服务器只转发操作指令不计算游戏状态。每个客户端在相同的帧号执行相同的指令理论上得到相同的结果。优点是带宽极低只传操作服务器压力小天然支持回放。缺点是要求逻辑完全确定性浮点数计算、随机数、物理引擎都可能导致不同步排查起来非常痛苦。状态同步State Synchronization的核心思路是服务器计算游戏状态定期同步给客户端客户端做插值和预测。优点是逻辑在服务器客户端改不了安全性高不同步问题少。缺点是带宽消耗大服务器压力大需要做延迟补偿和预测回滚。我的选择是核心战斗用帧同步外围系统用状态同步。具体来说英雄移动、技能释放、伤害判定走帧同步聊天、商店、匹配、结算走状态同步。这样既保证了战斗的公平性和一致性又避免了把所有逻辑都塞进帧同步带来的复杂度。帧同步的确定性要求极高。我踩过的坑包括Unity的Mathf.PerlinNoise在不同平台结果不一致、Dictionary的遍历顺序不确定、float精度在不同CPU架构上有差异。解决方案是所有战斗逻辑用定点数FixedPoint代替浮点数所有容器用有序结构随机数用自定义的线性同余生成器并同步种子。2.4 Protobuf在MOBA中的角色ProtobufProtocol Buffers在这个项目里承担两个职责网络消息序列化和配置表存储。网络消息用Protobuf的好处是体积小、解析快、跨语言。帧同步的操作指令包如果用JSON传一个移动指令可能要几十字节用Protobuf可以压到几个字节。在高峰期每秒几十个指令的情况下这个差距很可观。配置表用Protobuf的好处是可以二进制存储加载速度快而且有强类型约束。我习惯把英雄属性、技能参数、装备数据都定义成.proto文件然后用工具生成C#类再写一个Excel转Protobuf的管线。这样策划改数值只需要改Excel程序自动生成二进制配置。// 英雄基础属性定义 message HeroConfig { int32 heroId 1; string name 2; float baseHp 3; float hpGrowth 4; float baseAttack 5; float attackGrowth 6; repeated SkillConfig skills 7; } // 帧同步操作指令 message FrameCommand { int32 frameId 1; int32 playerId 2; CommandType type 3; bytes payload 4; }注意Protobuf的float在帧同步逻辑里要慎用建议用int32传定点数或者用sint32传缩放后的整数。3. 核心战斗系统的实现细节3.1 帧同步循环与确定性保证帧同步的核心是一个固定时间步长的循环。我用的逻辑帧率是30帧/秒也就是每33.3毫秒执行一次逻辑更新。渲染帧率可以更高通过插值让画面流畅。public class FrameSyncManager { private const int LOGIC_FRAME_RATE 30; private const float FRAME_INTERVAL 1f / LOGIC_FRAME_RATE; private int currentFrameId 0; private float accumulator 0f; private ListFrameCommand pendingCommands new ListFrameCommand(); public void Update(float deltaTime) { accumulator deltaTime; while (accumulator FRAME_INTERVAL) { accumulator - FRAME_INTERVAL; ExecuteFrame(currentFrameId, pendingCommands); currentFrameId; pendingCommands.Clear(); } } private void ExecuteFrame(int frameId, ListFrameCommand commands) { // 按playerId排序保证执行顺序一致 commands.Sort((a, b) a.playerId.CompareTo(b.playerId)); foreach (var cmd in commands) { CommandDispatcher.Dispatch(cmd); } BattleWorld.Instance.Tick(); } }确定性保证的几个关键点定点数运算所有位置、速度、伤害计算用FixedPoint结构体底层用long存储小数点后保留16位。加减乘除都有确定性实现。随机数同步每个逻辑帧开始时用帧号和种子生成随机数序列。所有客户端用相同的种子和帧号得到相同的随机结果。容器有序所有遍历用的容器用List或SortedDictionary不用Dictionary或HashSet。避免物理引擎Unity的PhysX在不同平台结果不一致战斗逻辑里的碰撞检测自己写AABB或圆形检测。3.2 技能系统的设计与实现MOBA的技能系统复杂度在于技能类型多、效果组合多、触发时机多。我见过用继承写技能系统的最后类爆炸到几百个。正确的做法是组合式设计技能 触发条件 效果列表 目标选择器。public class Skill { public int skillId; public SkillTrigger trigger; // 触发方式主动、被动、命中触发 public ListSkillEffect effects; // 效果列表伤害、治疗、位移、Buff public TargetSelector selector; // 目标选择单体、范围、方向、自身 public float cooldown; public float manaCost; public int castRange; }举个例子一个“向指定方向释放冲击波对路径上敌人造成伤害并减速”的技能配置如下trigger主动释放selector方向选择宽度200长度800effectsDamageEffect基础伤害200 法强加成0.7SlowEffect减速30%持续2秒BuffEffect给自身添加“施法后加速”Buff这样设计的好处是新增技能只需要配置数据不需要写新代码。策划可以在Excel里组合出几百个技能程序只需要维护十几种Effect和Selector的实现。技能释放的流程客户端检测输入发送CastSkillCommand到服务器或帧同步队列逻辑帧执行时检查冷却、蓝量、施法距离通过Selector计算目标列表依次执行Effect列表生成表现层事件特效、音效、飘字实操心得技能的前摇和后摇不要用Coroutine或Invoke要用逻辑帧计数。比如前摇0.3秒就是9个逻辑帧在第9帧执行效果。这样帧同步才能对齐。3.3 单位移动与寻路MOBA的移动有两个层面点击移动和强制位移。点击移动的流程是玩家点击地面客户端发送目标位置服务器或帧同步逻辑计算路径单位沿路径移动。寻路用A算法网格大小根据地图尺寸定。我用的网格是1米一个格子地图200x200A的开放列表用二叉堆优化单次寻路在1毫秒以内。强制位移如击飞、击退、闪现不走寻路直接设置位置或速度但需要处理碰撞。我的做法是强制位移期间关闭寻路用简单的圆形碰撞检测如果撞到不可穿越的障碍就停止。public class UnitMovement { private FixedPoint2 currentPos; private FixedPoint2 targetPos; private ListFixedPoint2 path; private int pathIndex; private FixedPoint moveSpeed; public void Tick() { if (path null || pathIndex path.Count) return; var next path[pathIndex]; var dir (next - currentPos).Normalized(); var step moveSpeed * FrameSyncManager.FRAME_INTERVAL; if ((next - currentPos).Magnitude() step) { currentPos next; pathIndex; } else { currentPos dir * step; } } }注意帧同步下的寻路必须确定性。A*的开放列表排序要稳定相同F值的节点按坐标排序不能用随机顺序。3.4 战斗结算与伤害计算伤害计算看起来简单但要做到“可配置、可扩展、可回放”并不容易。我的方案是管线式结算基础伤害 技能配置的基础值 攻击力/法强加成护甲减免物理伤害 × (100 / (100 护甲))魔抗减免法术伤害 × (100 / (100 魔抗))伤害加成检查攻击方的Buff如“增加10%伤害”伤害减免检查受击方的Buff如“减少15%受到伤害”暴击判定用帧同步随机数暴击则乘以暴击倍率最终伤害取整扣血public class DamagePipeline { public static int Calculate(DamageInput input) { float damage input.baseDamage input.attackRatio * input.attacker.attack; if (input.damageType DamageType.Physical) { damage * 100f / (100f input.defender.armor); } else if (input.damageType DamageType.Magic) { damage * 100f / (100f input.defender.magicResist); } foreach (var buff in input.attacker.buffs) { damage * buff.damageMultiplier; } foreach (var buff in input.defender.buffs) { damage * buff.damageReduction; } if (input.canCrit RollCrit(input.attacker.critChance)) { damage * input.attacker.critMultiplier; } return Mathf.Max(1, (int)damage); } }实操心得伤害计算里的浮点数在帧同步下要特别小心。我建议所有中间计算用定点数最后取整。暴击判定用帧同步随机数不要用UnityEngine.Random。4. 网络层与数据同步的实操方案4.1 帧同步的网络传输帧同步的网络层相对简单客户端上传操作服务器广播操作。但有几个细节要注意操作收集客户端在本地缓存操作每100毫秒3个逻辑帧打包发送一次。服务器收到后按帧号排序广播给所有客户端。帧号对齐服务器维护一个全局帧号所有客户端以服务器的帧号为准。如果某个客户端的帧号落后服务器会等待如果领先服务器会丢弃多余的操作。断线重连客户端重连后服务器发送从断线帧到当前帧的所有操作客户端快速追帧。追帧期间不渲染只跑逻辑。// 客户端操作收集 public class CommandCollector { private ListFrameCommand buffer new ListFrameCommand(); private float sendTimer 0f; private const float SEND_INTERVAL 0.1f; public void Update(float deltaTime) { sendTimer deltaTime; if (sendTimer SEND_INTERVAL) { sendTimer 0f; if (buffer.Count 0) { NetworkManager.SendCommands(buffer); buffer.Clear(); } } } public void AddCommand(FrameCommand cmd) { buffer.Add(cmd); } }4.2 状态同步的插值与预测外围系统如英雄属性、装备、金币用状态同步。服务器每秒同步10次客户端做插值。对于自己的英雄客户端做预测本地立即执行操作服务器确认后如果位置有偏差做平滑回滚。public class StateInterpolator { private QueueStateSnapshot snapshots new QueueStateSnapshot(); private const float INTERP_DELAY 0.1f; public void AddSnapshot(StateSnapshot snapshot) { snapshots.Enqueue(snapshot); } public StateSnapshot GetInterpolated(float currentTime) { float targetTime currentTime - INTERP_DELAY; // 找到targetTime前后的两个快照做线性插值 // ... } }注意插值延迟和网络延迟要平衡。延迟设太大操作手感变差设太小网络抖动会导致画面卡顿。我实测下来100毫秒的插值延迟在4G网络下比较稳。4.3 Protobuf消息定义与编解码网络消息用Protobuf定义每个消息有唯一的消息ID。编解码流程发送方构造Protobuf对象 → 序列化为字节数组 → 加消息头消息ID 长度→ 发送接收方读消息头 → 根据消息ID找到对应的解析器 → 反序列化 → 分发到处理器public class MessageCodec { public static byte[] Encode(int msgId, IMessage msg) { byte[] body msg.ToByteArray(); byte[] result new byte[body.Length 8]; BitConverter.GetBytes(msgId).CopyTo(result, 0); BitConverter.GetBytes(body.Length).CopyTo(result, 4); body.CopyTo(result, 8); return result; } public static (int msgId, IMessage msg) Decode(byte[] data) { int msgId BitConverter.ToInt32(data, 0); int length BitConverter.ToInt32(data, 4); byte[] body new byte[length]; Array.Copy(data, 8, body, 0, length); var parser MessageRegistry.GetParser(msgId); return (msgId, parser.ParseFrom(body)); } }实操心得Protobuf的ToByteArray()每次都会分配新数组高频调用会产生GC压力。可以用CodedOutputStream配合MemoryStream复用缓冲区减少GC。5. 常见问题与排查技巧实录5.1 帧同步不同步的排查思路不同步是帧同步最头疼的问题。我的排查流程是确认不同步的帧号让每个客户端记录每帧的状态哈希所有单位的位置、血量、Buff的哈希值对比哪个帧开始不一致。定位到具体单位如果哈希不一致打印所有单位的状态找到第一个不一致的单位。检查该单位的逻辑看该帧执行了什么操作涉及哪些计算。常见原因浮点数精度问题改用定点数容器遍历顺序问题改用有序容器随机数不同步检查随机数种子和调用次数逻辑帧执行顺序问题检查指令排序规则问题现象可能原因解决方案偶尔不同步浮点数精度全部改定点数特定英雄不同步该英雄技能有随机逻辑随机数用帧同步种子团战时不同步容器遍历顺序改用List并排序重连后不同步追帧逻辑有误检查追帧时的指令执行5.2 技能效果不触发的排查技能效果不触发通常有几个原因冷却未好检查冷却计时是否用逻辑帧驱动蓝量不足检查蓝量扣除时机施法距离不够检查距离计算是否用定点数目标选择为空检查Selector的筛选条件Buff免疫检查目标是否有免疫Buff我的做法是在技能释放的每个环节打日志包括冷却检查、蓝量检查、距离检查、目标选择、效果执行。日志用帧号标记方便对比。5.3 移动卡顿与回拉移动卡顿通常是网络问题。排查步骤检查客户端预测是否开启检查服务器同步频率检查插值延迟设置检查网络抖动情况如果服务器同步频率太低比如每秒5次移动会明显卡顿。我建议状态同步频率至少每秒10次帧同步的操作广播至少每秒30次。实操心得移动回拉橡皮筋效应通常是客户端预测和服务器结果偏差太大。解决方案是客户端预测时用相同的移动逻辑服务器确认后如果偏差小于阈值就忽略大于阈值才回滚。阈值设0.5米左右比较合适。5.4 内存与GC优化MOBA战斗场景单位多、特效多、消息多GC压力大。优化手段对象池单位、特效、飘字、消息对象全部用对象池避免装箱Protobuf消息用泛型接口避免object装箱减少字符串日志用条件编译正式版关闭复用缓冲区网络收发用固定大小的byte数组public class ObjectPoolT where T : new() { private StackT pool new StackT(); public T Get() { return pool.Count 0 ? pool.Pop() : new T(); } public void Return(T obj) { pool.Push(obj); } }我实测下来做好对象池之后战斗中的GC Alloc可以从每帧几KB降到几百字节基本不会触发GC卡顿。6. 从Demo到产品的扩展方向6.1 微信小游戏适配如果要做微信小游戏版本Unity的WebGL导出是基础。但有几个坑包体限制微信小游戏首包有大小限制需要用Addressable做分包首包只放核心资源视频播放Unity WebGL的视频播放需要特殊处理可以用微信的VideoPlayer接口性能小游戏的性能比原生App差需要降低渲染质量、减少单位数量网络小游戏的网络用WebSocket延迟比原生Socket高插值延迟要调大6.2 回放系统帧同步天然支持回放记录所有帧的操作指令回放时按帧执行即可。回放系统的关键是记录每帧的指令列表记录随机数种子回放时用相同的逻辑代码执行支持快进、暂停、跳转public class ReplaySystem { private ListFrameCommands replayData; private int currentFrame; public void Play() { var commands replayData[currentFrame]; FrameSyncManager.ExecuteFrame(currentFrame, commands.commands); currentFrame; } }6.3 观战系统观战系统比回放复杂因为观战是实时的。方案是观战者作为特殊客户端加入服务器同步所有操作和状态观战者本地执行逻辑。观战者可以自由切换视角但不发送操作指令。注意观战者的逻辑帧可能落后于战斗客户端需要做追帧处理。追帧时快速执行多帧逻辑直到追上当前帧。6.4 反作弊帧同步的反作弊是个难题因为逻辑在客户端。我的方案是服务器校验服务器也跑一份逻辑定期对比客户端的状态哈希操作频率限制限制每个玩家的操作频率防止脚本关键判定服务器化伤害计算、技能命中判定在服务器做二次校验行为分析记录玩家操作分析异常模式7. 一些踩坑后的个人体会做MOBA这两年最大的体会是架构设计比写代码重要十倍。我见过太多项目因为一开始架构没搭好后期改一个功能要动十几个文件最后只能推倒重来。第二个体会是帧同步的确定性是磨出来的。没有哪个项目一开始就完全同步都是靠状态哈希对比、逐帧排查、一个个坑填出来的。我建议在项目早期就加入状态哈希校验每帧对比一旦不同步立即报警这样问题刚出现就能定位。第三个体会是不要过早优化。我一开始就想着要做100个英雄、要支持万人同屏结果光架构就搭了两个月一个能玩的Demo都没出来。后来调整思路先做两个英雄、一条兵线跑通闭环再逐步扩展。事实证明这个路径是对的。最后分享一个小技巧用Excel管理技能配置。策划在Excel里填数值程序写一个导出工具生成Protobuf二进制。这样策划改数值不需要程序介入迭代速度会快很多。导出工具用Python写读Excel用openpyxl生成Protobuf用protobuf库整个管线半天就能搭好。这个项目后续还可以扩展的方向很多比如加入PVE模式、加入赛季系统、加入皮肤系统、加入社交系统。但核心的战斗框架和网络架构是基础把这部分做扎实了上面的内容都是堆量的问题。
返回列表