ARTICLE DETAIL

资讯详情

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

从零搭建MOBA原型:Unity战斗闭环与技能系统实战

从零搭建MOBA原型:Unity战斗闭环与技能系统实战 1. 从零搭建一个MOBA原型先想清楚最小可玩闭环是什么很多人一上来就想做英雄联盟然后打开Unity或者Unreal Engine建了个地形放了个胶囊体就不知道下一步该干嘛了。我见过太多这样的项目文件夹里躺着十几个新建 Unity 项目每个都活不过三天。问题不在于引擎难学而在于没想清楚一件事MOBA的最小可玩闭环到底是什么。我的答案很直接一张对称地图、两个可操控角色、一套技能系统、一个能扣血和复活的逻辑、一个胜负判定。就这五样。没有小兵、没有野怪、没有装备商店、没有排位赛这些都可以后面加。但如果你连两个角色能互相打、能放技能、能死、能赢都跑不通后面加再多系统都是空中楼阁。这个判断不是拍脑袋来的。MOBA的核心体验是对抗中的决策而决策的前提是有一套完整的战斗循环。你先把战斗循环跑通哪怕画面是方块人、技能是白色粒子特效你也能验证这个游戏好不好玩。反过来你先把地图做得跟召唤师峡谷一样精细结果发现角色移动手感像在冰面上滑那前面的美术投入全白费。所以这篇文章的路线是先跑通战斗闭环再逐步替换和扩展。引擎层面我以Unity为主来展开因为热词里Unity相关的内容占了绝大多数而且Unity在快速原型阶段的迭代效率确实高。Unreal Engine我会在关键差异点上做对比说明方便你根据自己的技术栈做选择。1.1 为什么选Unity而不是Unreal来做MOBA原型这个问题没有标准答案但有几个实际的考量维度。Unity的优势在于迭代速度快、C#上手门槛低、社区资源丰富。你要做一个MOBA原型需要频繁地改数值、调手感、加技能Unity的热重载和编辑器扩展能力让这个过程非常顺畅。而且热词里提到的unity 2022中文版下载、unity安装、unity下载教程这些说明大量开发者是从Unity入门的生态成熟度摆在那里。Unreal的优势在于画面表现力强、网络同步框架成熟。如果你目标是做一个画面接近商业品质的MOBAUnreal的Nanite和Lumen确实能省很多事。但代价是C的学习曲线更陡蓝图虽然能快速搭逻辑但复杂技能系统最终还是得回到C。我的建议是原型阶段用Unity验证玩法后再决定是否迁移。因为MOBA的乐趣核心在于数值和机制设计不在于画面。你用一个方块人也能验证这个技能组合是否有趣等玩法确定了再考虑画面升级。1.2 项目目录结构从第一天就避免脚本乱扔我踩过最大的坑就是一开始把所有脚本都扔在Assets/Scripts下面等到项目有五十个脚本的时候找一个PlayerController要翻半天。后来我固定了一套目录结构你可以直接抄Assets/ _Project/ Scripts/ Core/ # 游戏管理器、事件系统、状态机 Combat/ # 伤害计算、技能系统、Buff系统 Entities/ # 英雄、小兵、防御塔的逻辑 Networking/ # 网络同步相关 UI/ # 界面逻辑 Data/ # ScriptableObject定义和配置 Prefabs/ Scenes/ Art/ Audio/关键是_Project这个下划线前缀它会让你的文件夹排在Assets列表最前面不会被Unity自带的Standard Assets之类的包淹没。这个习惯我从第二个项目开始保持到现在省下来的找文件时间至少有好几十个小时。2. 英雄移动与摄像机手感是MOBA的第一生命线MOBA的移动手感有多重要你可以做个实验把英雄联盟里的鼠标右键移动改成WASD你会立刻觉得这不是英雄联盟。移动方式决定了玩家的操作直觉而操作直觉决定了游戏的上手难度。2.1 右键点地移动的底层逻辑MOBA的移动本质是点击地面→角色寻路到目标点。Unity里实现这个的标准做法是NavMeshAgent加上射线检测。但这里有个细节很多人会忽略你不能直接用NavMeshAgent.SetDestination就完事了因为MOBA的移动需要支持移动中改变目标点和攻击时自动停下。我的做法是写一个HeroMovement组件内部维护一个状态机public enum MoveState { Idle, Moving, Attacking, Channelling } public class HeroMovement : MonoBehaviour { private NavMeshAgent agent; private MoveState currentState MoveState.Idle; void Update() { if (currentState MoveState.Moving !agent.pathPending) { if (agent.remainingDistance agent.stoppingDistance) { currentState MoveState.Idle; } } } public void MoveTo(Vector3 target) { if (currentState MoveState.Channelling) return; agent.SetDestination(target); currentState MoveState.Moving; } public void StopForAttack() { agent.ResetPath(); currentState MoveState.Attacking; } }这个状态机的关键在于Channelling状态——当英雄在引导技能时移动指令应该被忽略。这个细节在原型阶段很容易漏掉等到你发现引导技能时还能跑就晚了。2.2 摄像机跟随为什么不能用简单的父子关系新手最常见的做法是把摄像机设为英雄的子物体这样英雄走到哪摄像机跟到哪。但MOBA的摄像机不是这样的——摄像机是固定角度俯视跟随的是英雄的位置但不跟随旋转。正确的做法是摄像机独立于英雄在LateUpdate里平滑跟随public class MOBACamera : MonoBehaviour { public Transform target; public float smoothSpeed 5f; public Vector3 offset new Vector3(0, 12, -8); void LateUpdate() { if (target null) return; Vector3 desiredPos target.position offset; transform.position Vector3.Lerp(transform.position, desiredPos, smoothSpeed * Time.deltaTime); transform.rotation Quaternion.Euler(55f, 0, 0); } }用LateUpdate而不是Update是因为要确保英雄先移动完摄像机再跟随否则会出现画面抖动。这个坑我在第一个项目里踩了整整两天一直以为是帧率问题后来才发现是更新顺序的问题。提示如果你的MOBA支持视角旋转比如按空格键回到英雄视角摄像机的旋转要用Quaternion.Slerp做插值不要直接赋值否则会晕。2.3 移动手感调优加速度和转向速度NavMeshAgent默认的加速度和转向速度是给RTS游戏用的直接拿来用会觉得英雄很飘。我的经验值是参数默认值MOBA推荐值说明Speed3.56.5英雄移动速度Angular Speed120720转向速度要快Acceleration830加速度要跟手Stopping Distance00.1停止距离避免抖动转向速度调到720的意思是英雄几乎瞬间转向这在MOBA里是必须的——你点右边英雄就应该立刻朝右走不能有个转身动画。加速度调到30是为了让移动有轻微的起步感但不会觉得迟钝。3. 技能系统MOBA的灵魂在于每个技能都不一样如果说移动是MOBA的骨架那技能就是MOBA的灵魂。英雄联盟有一百多个英雄每个英雄有四个技能加一个被动这些技能的组合方式决定了游戏的深度。但你在做原型的时候不需要做一百个技能你需要做的是一套能支持各种技能类型的框架。3.1 技能系统的三种架构方案对比在动手写代码之前你得先决定技能系统怎么架构。我试过三种方案各有优劣方案一每个技能一个脚本。最简单直接FireballSkill.cs、DashSkill.cs各写各的。优点是上手快缺点是技能多了之后代码重复严重改一个公共逻辑要改几十个文件。方案二数据驱动加通用执行器。技能定义在ScriptableObject里包含伤害、范围、冷却等参数运行时由一个SkillExecutor统一执行。优点是扩展性好缺点是遇到特殊机制比如亚索的E需要记录路径就得加特例。方案三行为树或状态机驱动。每个技能是一个行为树节点定义检测目标→播放动画→造成伤害→进入冷却。优点是灵活度最高缺点是学习成本高原型阶段容易过度设计。我的建议是原型阶段用方案一验证玩法后用方案二重构。因为原型阶段你需要的是快速试错不是优雅的架构。等你确定了要做哪几个技能再把公共逻辑抽出来。3.2 技能释放的完整流程拆解一个技能从按键到生效中间经历了什么我把它拆成六个阶段输入检测玩家按下Q键Input.GetKeyDown(KeyCode.Q)触发前置检查技能是否在冷却法力值够不够是否被沉默目标选取需要目标吗是点地释放还是指向释放前摇播放施法动画这段时间可以被控制技能打断效果生效造成伤害、施加Buff、生成投射物后摇与冷却播放收招动画技能进入冷却这六个阶段里前摇和后摇是最容易被忽略但最影响手感的。前摇太长玩家觉得按了没反应后摇太长玩家觉得放完技能动不了。英雄联盟里不同英雄的前后摇差异很大比如锐雯的Q几乎没有后摇而拉克丝的大招后摇很长。public class SkillBase : MonoBehaviour { public float cooldown 5f; public float castTime 0.3f; // 前摇 public float recoveryTime 0.2f; // 后摇 public float manaCost 50f; private float lastCastTime -999f; private bool isCasting false; public bool CanCast(HeroStatus status) { if (Time.time - lastCastTime cooldown) return false; if (status.currentMana manaCost) return false; if (status.IsSilenced) return false; if (isCasting) return false; return true; } public void TryCast(HeroStatus status, Vector3 target) { if (!CanCast(status)) return; StartCoroutine(CastRoutine(status, target)); } private IEnumerator CastRoutine(HeroStatus status, Vector3 target) { isCasting true; status.currentMana - manaCost; // 前摇 yield return new WaitForSeconds(castTime); // 效果生效 ExecuteEffect(status, target); // 后摇 yield return new WaitForSeconds(recoveryTime); lastCastTime Time.time; isCasting false; } protected virtual void ExecuteEffect(HeroStatus status, Vector3 target) { } }这个基类把冷却、法力、前摇后摇都处理了子类只需要重写ExecuteEffect。这样你写十个技能公共逻辑只写一遍。3.3 投射物与碰撞检测用Physics.SphereCast还是触发器热词里出现了unity physics.spherecast说明很多人关心这个问题。投射物比如火球、寒冰箭的碰撞检测有两种主流做法触发器方案给投射物加一个SphereCollider勾选Is Trigger在OnTriggerEnter里处理命中。优点是简单缺点是高速投射物会穿透目标因为物理帧率不够。射线检测方案每帧从投射物当前位置向上一个位置发射一条射线检测是否命中。优点是高速投射物不会穿透缺点是需要手动处理碰撞。我的选择是射线检测因为MOBA里的投射物速度通常很快触发器方案在低帧率下会漏判。具体实现public class Projectile : MonoBehaviour { public float speed 15f; public float maxDistance 12f; public LayerMask hitMask; private Vector3 startPos; private Vector3 lastPos; void Start() { startPos transform.position; lastPos transform.position; } void Update() { float moveDistance speed * Time.deltaTime; Vector3 direction transform.forward; RaycastHit hit; if (Physics.SphereCast(lastPos, 0.3f, direction, out hit, moveDistance, hitMask)) { OnHit(hit.collider); Destroy(gameObject); return; } transform.position direction * moveDistance; lastPos transform.position; if (Vector3.Distance(startPos, transform.position) maxDistance) { Destroy(gameObject); } } protected virtual void OnHit(Collider other) { } }SphereCast的半径设0.3是为了模拟投射物的体积避免擦边没打中的情况。这个值可以根据投射物的视觉大小调整。4. 网络同步MOBA最容易被低估的技术难点如果你只做单机原型这一章可以跳过。但MOBA的本质是多人对抗网络同步是绕不过去的坎。热词里出现了protobuf说明很多人已经在考虑序列化方案了这是对的。4.1 帧同步还是状态同步MOBA的选择MOBA游戏几乎都用帧同步Lockstep而不是状态同步。原因很简单状态同步需要服务器频繁广播每个单位的位置和状态带宽消耗大帧同步只需要广播玩家的操作指令每个客户端自己计算游戏状态。帧同步的核心要求是确定性——同样的输入在每台机器上必须产生同样的输出。这意味着你不能用UnityEngine.Random得用自己实现的确定性随机数不能用浮点数做物理计算得用定点数。public class DeterministicRandom { private uint seed; public DeterministicRandom(uint initialSeed) { seed initialSeed; } public uint Next() { seed ^ seed 13; seed ^ seed 17; seed ^ seed 5; return seed; } public float NextFloat() { return Next() / (float)uint.MaxValue; } }这个XORShift算法是帧同步里常用的确定性随机数生成器同样的种子在任何机器上产生同样的序列。4.2 Protobuf在MOBA网络层的实际用法热词里protobuf出现了我猜很多人想知道它在MOBA里怎么用。Protobuf的核心优势是序列化后体积小、解析速度快非常适合帧同步里频繁传输的操作指令。一个典型的帧同步数据包结构message FrameInput { uint32 frameId 1; repeated PlayerInput inputs 2; } message PlayerInput { uint32 playerId 1; InputType type 2; float targetX 3; float targetY 4; uint32 skillId 5; } enum InputType { MOVE 0; SKILL 1; ATTACK 2; RECALL 3; }每个逻辑帧通常是66ms即15帧每秒打包一次所有玩家的输入广播给所有客户端。客户端收到后在本地执行这些输入推进游戏状态。注意帧同步里所有逻辑帧必须严格对齐不能因为某台机器卡顿就跳过帧。通常的做法是追帧——如果本地落后了就加速执行直到追上。4.3 网络延迟的补偿为什么你看到的和服务器不一样帧同步的一个固有问题是输入延迟。你按下Q键这个操作要先发给服务器服务器广播给所有人然后所有客户端在同一帧执行。这意味着你的操作至少有一个RTT的延迟才能看到效果。英雄联盟的解决方案是客户端预测本地按下Q键后立即播放动画和特效同时把操作发给服务器。如果服务器确认了就继续如果服务器拒绝了比如你其实被沉默了就回滚本地状态。这个回滚机制实现起来很复杂原型阶段可以先不做但你要知道它的存在。等你发现按了技能没反应或者技能放了但没伤害的时候就是需要处理预测和回滚的时候了。5. 性能优化MOBA同屏单位多优化要提前做MOBA到了后期十个人加上几十个小兵和防御塔同屏单位可能上百。如果不做优化帧率会掉得很难看。热词里unity游戏优化和unity 优化 限定数据块大小都指向这个方向。5.1 对象池不要频繁Instantiate和Destroy小兵和投射物是频繁生成和销毁的直接用Instantiate和Destroy会产生大量GC垃圾回收导致帧率波动。对象池的思路是预先创建一批对象不用的时候隐藏起来需要的时候拿出来复用。public class ObjectPoolT where T : Component { private QueueT pool new QueueT(); private T prefab; private Transform parent; public ObjectPool(T prefab, int initialSize, Transform parent) { this.prefab prefab; this.parent parent; for (int i 0; i initialSize; i) { T obj Object.Instantiate(prefab, parent); obj.gameObject.SetActive(false); pool.Enqueue(obj); } } public T Get() { if (pool.Count 0) { T obj Object.Instantiate(prefab, parent); return obj; } T pooled pool.Dequeue(); pooled.gameObject.SetActive(true); return pooled; } public void Return(T obj) { obj.gameObject.SetActive(false); pool.Enqueue(obj); } }对象池的初始大小要根据实际峰值来定。小兵池我一般设50个投射物池设100个。设少了会在高峰期频繁创建新对象设多了浪费内存。5.2 数据块大小限定为什么你的技能配置加载那么慢热词里unity 优化 限定数据块大小这个点很具体我猜是有人在加载大量技能配置时遇到了性能问题。Unity的ScriptableObject在加载时会一次性读取整个资源如果你的技能配置表有几万行加载时间会很长。解决方案是分块加载把技能配置按英雄分成多个ScriptableObject每个英雄一个文件用到哪个加载哪个。或者用Addressables做异步加载避免主线程卡顿。// 不推荐所有技能在一个大表里 public class AllSkillsConfig : ScriptableObject { public ListSkillData allSkills; // 几万条数据 } // 推荐按英雄分块 public class HeroSkillConfig : ScriptableObject { public string heroId; public ListSkillData skills; // 只有这个英雄的4-5个技能 }这个改动看起来简单但在实际项目里能把加载时间从几秒降到几十毫秒。5.3 UI显示隐藏SetActive还是改LocalScale热词里有个很具体的问题unity ui显示隐藏是setactive还是改localscale还是移出相机。我的答案是看情况。SetActive(false)完全禁用GameObject不参与渲染和更新。适合长期不用的UI。改LocalScale为0对象还在只是看不见。适合频繁切换的UI因为SetActive有额外的开销。移出相机视野对象还在渲染只是不在画面里。不推荐因为仍然消耗渲染资源。MOBA里的技能冷却图标、血条这些频繁更新的UI我建议用改LocalScale或者改CanvasGroup.alpha。而设置面板这种打开频率低的用SetActive就行。6. 从原型到可玩Demo还需要补哪些课跑通战斗闭环之后你的项目已经能玩了。但如果想做成一个像样的Demo还有几件事要做。6.1 小兵与防御塔的AI逻辑小兵的行为很简单沿着固定路线走遇到敌人就打。用状态机实现public enum MinionState { Marching, Attacking, Dead } public class MinionAI : MonoBehaviour { private MinionState state MinionState.Marching; private Transform currentTarget; void Update() { switch (state) { case MinionState.Marching: MarchAlongPath(); CheckForEnemy(); break; case MinionState.Attacking: AttackTarget(); break; } } private void CheckForEnemy() { Collider[] enemies Physics.OverlapSphere(transform.position, detectRange, enemyMask); if (enemies.Length 0) { currentTarget enemies[0].transform; state MinionState.Attacking; } } }防御塔的逻辑类似但优先级不同优先攻击小兵其次攻击英雄。这个优先级规则是MOBA的基本设计你在实现的时候要注意。6.2 装备与商店系统的最小实现装备系统的核心是属性加成。每件装备提供一些属性攻击力、护甲、冷却缩减购买后叠加到英雄身上。[CreateAssetMenu(fileName NewItem, menuName MOBA/Item)] public class ItemData : ScriptableObject { public string itemName; public int cost; public float attackDamage; public float armor; public float cooldownReduction; public Sprite icon; }商店UI就是一个列表点击购买后扣钱、加属性、把装备加到英雄的装备栏里。原型阶段不需要做合成系统直接买成品就行。6.3 回放与观战帧同步的天然优势帧同步有一个额外的好处回放系统几乎免费。因为游戏状态完全由输入决定你只需要记录每一帧的所有输入回放的时候重新执行一遍就行了。public class ReplayRecorder { private ListFrameInput recordedFrames new ListFrameInput(); public void RecordFrame(FrameInput input) { recordedFrames.Add(input); } public void SaveReplay(string path) { // 用Protobuf序列化后存文件 using (var stream File.Create(path)) { foreach (var frame in recordedFrames) { frame.WriteDelimitedTo(stream); } } } }这个功能在调试的时候特别有用——你可以录下一局游戏然后反复回放看某个时间点到底发生了什么。7. 一些踩过的坑和实际经验最后分享几个我在做MOBA原型过程中踩过的坑都是文档里不会写的。第一个坑NavMesh的烘焙范围。Unity的NavMesh默认只烘焙场景里静态物体的可行走区域。如果你的地图是程序化生成的或者有动态障碍物需要在运行时重新烘焙。我当时的解决方案是用NavMeshBuilder在加载地图后动态烘焙但这个过程很慢后来改成了预烘焙多个区域运行时拼接。第二个坑技能指示器的实现。英雄联盟里每个技能的指示器形状都不一样——有圆形、有扇形、有直线。我一开始想用一个通用组件搞定后来发现不同形状的数学计算差异太大最后还是每个形状写了一个独立的指示器脚本。这个教训是不要为了抽象而抽象该重复的地方就重复。第三个坑网络同步的浮点数问题。帧同步要求确定性但Unity的Vector3用的是浮点数不同平台的浮点运算结果可能有微小差异。这个差异在短时间内看不出来但跑几千帧之后就会导致状态不一致。解决方案是用定点数替代浮点数或者定期做状态校验和回滚。这个问题我在项目后期才发现重构花了很多时间。第四个坑UI事件穿透。MOBA里点击UI的时候不能触发地面移动。Unity的EventSystem默认会处理这个问题但如果你用了自定义的输入系统需要手动检查EventSystem.current.IsPointerOverGameObject()。这个坑很隐蔽因为大部分时候是正常的只有在UI边缘点击时才会出问题。第五个坑技能冷却的UI同步。技能冷却好了之后UI上的遮罩要消失。我一开始用Update里每帧检查冷却状态后来发现这样每帧都在改UI性能很差。改成用事件驱动——技能冷却结束时触发一个事件UI监听这个事件来更新。这个改动让UI的CPU占用降了一半。这些经验不一定适用于你的项目但至少能让你知道哪些地方容易出问题。做MOBA原型最大的感受是技术难点往往不在你预想的地方。你以为难的是网络同步结果卡住你的是UI事件穿透你以为难的是技能系统结果卡住你的是NavMesh烘焙。所以我的建议是尽早跑通完整流程哪怕每个环节都很粗糙。只有跑通了你才知道真正的瓶颈在哪里。
返回列表