# MoBaDemo **Repository Path**: nidao/MoBaDemo ## Basic Information - **Project Name**: MoBaDemo - **Description**: 用Unity做的一个类Moba游戏Demo - **Primary Language**: C# - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 6 - **Created**: 2021-11-12 - **Last Updated**: 2021-11-12 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 游戏整体逻辑 # ## 出兵逻辑 ## 游戏会在两个阵营的固定区域在固定的频率中出兵。小兵只能沿着规定好的轨道进行行走,并按照各个防守塔的顺序向敌方进行进攻。 ### 需要注意的点 ### 对于固定出现的小兵来说,不适合在其死亡之后删除GameObject对象,而应该使用对象池来管理他们,当小兵死亡时,只是将该单位的GameObject的active设为false,当要进行出兵的时候,从对象池中抽取active为false的单位出现在固定区域。 ### 规定路径的寻路 ### 对于小兵来说,他们会固定的走上中下三条路线,不会穿越其中的任何一条,而Unity本身的Navigation导航系统不能对于一个地形进行分区,所以使用area对各个单位行走轨迹进行分区的方法就失败了。 在此,决定用**(伪)路径点寻路**的方法来控制小兵的行走轨迹,下面简单描述一下: 1. 因为小兵身上的行为树需要用到导航系统来对敌对目标进行导航,所以小兵的移动不能完全使用路径点寻路,所以这里加了个“伪”字 2. 对于上中下三条路,他们有一系列这条路上的路径点,当小兵没有遭遇战斗时,会沿着这些路径点进行行走,当小兵遭遇战斗,并且,战斗结束后,小兵会找到离他最近的一个路径点,然后走过去,然后又是沿着 路径点一路走。 3. 关键在于,如何判断小兵的战斗结束了,这里采用判断周围敌人的方法,当周围敌人数量为0,那么就是战斗结束了 4. 走到终点也是一种情况,当走到了路径点的终点,小兵将会停在终点处(一般将终点设置为离敌方基地很近,这样小兵停在终点就会自动向敌方基地进攻) ### 如何实现路径点寻路 ### 1. 基于一个WayPointManager的MonoBehavior类来管理上中下三路的路径点(使用MonoBehavior的原因是可以在编辑器编辑路径点) 2. 使用一个WayPointUnit来管理 单位上中下路的寻路 、 下一个路径点的寻找 、 寻找自己最近的路径点 功能。 3. 理论上来说,只有要不断的攻打敌方基地的小兵才是需要路径点寻路的,其他野怪或者英雄依然使用unity自带的Navigation导航寻路。所以WayPointsUnit这个类是小兵独有的。但是,如果就此就为小兵设置一个单独的具体类,就未免太过麻烦,这里依旧把WayPointsUnit属性赋予CharacterMono类,只不过只有小兵才对其进行赋值,而其他单位这个属性值均为Null。 4. 使用路径点寻路要改写单位的行为树。之前我的小兵行为树策略是指定敌人进行攻击的行为树,但是因为指定敌人的话,行走路线不能固定,所以要将指定攻击三塔、基地的行为树改造成 沿着路径点 指定行走的行为树,其基本逻辑用以下伪代码实现。 1 if(周围有敌人){ 2 攻击、追击周围敌人 3 if(如果当前位置离最近的路径点距离大于X){ 4 返回路径点,不管敌人 5 } 6 }else{ 7 沿着下一个路径点行走 8 } 这个AI之所以可以顺利的进攻敌人的基地和三路塔,靠的是1-5行的第一个if,路径点规定一定会走到敌方塔或基地前,固小兵可以顺利进攻这几个位置. 5. 最近路径点算法: 最近路径点的寻找是整个AI性能的关键之处,在这里,我将这个算法定义为如下情况: 从当前下标往下找,一直找到一个离自己最近的位置,那么"最近路径点"等于离自己最近的位置的路径点的下标+1,这里主要是为了小兵不会走回头路(那会看起来很怪).下面用伪代码来描述: for(int i=nowIndex+1;i这种数组来保存这些技能对象,最后通过skillList[ i ]来通过下标访问任意一个技能。 但是!!在本游戏中,每个单位都是有技能的,这些单位的技能毫无疑问应该是之前已经编辑好的技能,这会遇到以下几个问题: 1. 所有单位的技能都必须是技能表中的技能的深拷贝吗?(因为每个单位它的技能都会有自己的等级,正在缓冲的CD时间等,这个是每个单位都不一样的) 2. 其次,本游戏中物品、状态的设计同样是通过技能来实现的,物品、状态中的技能,也得是深拷贝技能列表里的技能的吗? 根据上面两点,可以知道如果单位、物品、状态技能都是深拷贝自技能列表中的技能,那么可能游戏的内存消耗会相当巨大了,因为很多时候技能都是有共性的。 #### 简单的深拷贝对象将带来巨大的内存消耗 举个例子,比如多个技能之间释放的技能特效可能是同一个特效对象(Prefab)。这个比较好解决,只要在深拷贝的时候,不深拷贝特效对象就行了。 但是,同种技能之间的共性更多在他们的MP消耗,技能范围,技能图标,技能描述等属性上面。 举个例子,在游戏中有一招“地狱火”技能,随着等级的提升,它的MP消耗,技能范围可能会扩大,但是他的技能描述、技能名、技能图标这些是不会变化的。 而如果有一个英雄(比如“恐惧魔王”),他会这招“地狱火”技能,那么按照上面说的方法,他就深拷贝了一份技能列表中的地狱火。此时内存里有一份地狱火的技能描述、技能名、技能图标等属性。 这时,又有一个物品(比如“恶魔岩石”),使用这个物品的效果同样是“地狱火”技能的效果,那么按照上面说的方法,这里又得深拷贝一份技能列表中的地狱火。此时内存已经有两份同样的地狱火技能描述、技能名、技能图标等属性了。这就会带来多余的内存占用率。 同样的思路,如果此时又有一个状态,比如死亡后会释放地狱火技能,又得深拷贝一份。 #### 技能数据中变化与不变的部分 这里其实可以敏锐的察觉到,其实,技能对象中,大多数属性是不用变化,不用深拷贝的,这些属性,不论这些技能是在什么对象上,无论这些技能是由英雄释放的,还是物品释放的,都是不变的。 而技能对象的另外一些属性,才是真正会发生变化的,比如它的等级(由英雄加点发生变化),缓存的CD时间(经过多长时间可以继续释放它,由上一次释放它的时间决定)等属性,是每个具体的技能对象不同的,而其余的基本都相同。 下面以主动技能为例,列举一下其中变化和不变的属性。 不变的属性有以下几种: 1. skillName 技能名 2. icon 技能图标 3. longDescription 技能长描述 4. shortDescription 技能短描述 5. backgroundDescription 技能背景描述 6. mp 消耗MP,数组类型,mp[ i ]表示该技能第i级时消耗的mp 7. spellDistance 施法距离,数组类型,spellDistance[ i ]表示该技能第i级时的施法距离 8. skillInfluenceRadius 技能影响范围,数组类型 9. cooldown 技能CD时间 数组类型 ......等等 变化的属性有以下几种: 1. skillLevel 技能等级,由英雄加点时变化 2. finalSpellTime 上一次释放这个技能的时间,由上一次释放该技能的时机决定,用来判断该技能是否到达CD时间 可以看到,每个技能不变的属性有相当多,而每个技能对象不同的属性却只有两个(暂时只有两个,或许后续会增加)。 这种时候,就应该使用**享元模式**来管理技能对象的数据。 #### 如何使用享元模式管理技能数据 使用享元模式管理,那么最后在每个单位上的技能不变的那部分属性都只需要引用技能列表中的技能就够了,而不变的属性则由它所依附的那个对象来使用。 那么如何使用享元模式来管理呢? 我的做法是,**新增一个SkillModel类**,每个Skill对象都会引用一个SkillModel对象,这个Model类中的属性即为这个Skill不变的属性,即技能名、技能描述等属性。 而该技能会变化的属性,即Level等属性,则作为Skill类的属性,在游戏中会发生变化。 最后,我们在外部编辑器编辑的SkillModel的属性,读取进来也是SkillModel对象,全局存储的也是类似List这类属性,而具体的技能则是必须通过SkillModel来进行创建。 这样,每次都需要深拷贝的一部分技能类的属性,就变成了公共的属性,只需要Skill类中存储一个指向这些Model类的指针的就行了。 #### 要为每一个技能子类(操作)都编写一个Model类对应吗? 前面提到,在本游戏中,由技能组设定(SkillGroup)类增加的技能的易扩展性,也就是多个操作来组成一个技能,技能的种类由操作的排列组合形成。 实际上,我们在外部编辑器中,输入的就是这些操作的参数。每个原子技能类都各自继承自ActiveSkill和PassiveSkill类,他们也会各自派生出其他属性。 举个例子,比如派生于ActiveSkill的AdditionStateSkill(为某个目标增加一个状态的技能),就会有一个独立属性StateID,即要附加的状态的ID,这是ActiveSkill所没有的。这个时候,按照上面的做法,还得再派生自基类ActiveSkillModel得到一个AdditionStateSkillModel类,若是有多个原子技能,就会有多个这样的Model类,如果要一个个都全部编写,那效率会比较低下。 这里我采取一个方案,在BaseSkillModel类中使用一个< string,object >类型的字典,存储这些所有的子类中的额外属性(相比于父类)。 而当要使用这些属性时,就直接从字典中取出该属性并进行强转。 ## 伤害系统的设计 ## 1. 所有单位受到的伤害都必须由Damage类提供,在代码中不允许出现 HP -= xxxx 这样的字眼. 2. Damage类是值类型 3. 某些被动技能拥有提升伤害的特性,对于这些被动技能,在计算完攻击 or 法术伤害后,再依次计算各个被动技能对伤害的提升。 4. Damage类本身用于计算伤害的属性皆为Float属性,但是,当要具体拿出来计算时,全部都使用Floor强转为int型。 ## 物品系统设计 ## ### 功能描述 ### 在MOBA游戏中,一个物品有多种功能。下面简单举例: 1. 作为消耗品,为英雄进行补血等操作 2. 作为装备,为英雄提供属性或特技 3. 作为某些永久性使用物品,给英雄进行使用 可以看出物品其实可以看成是拥有一堆主动、被动技能的物体,只要携带在英雄身上(物体未消耗完毕),英雄就可以使用这些主动、被动技能。 ### 物品类(Item)属性 ### 1. 单个物品可持有最大数量 MaxCount : int : <== 2. 物品类型(如:消耗品,装备等等) ItemType : ItemType : <== 3. 拥有的主动技能 ItemActiveSkill : ActiveSkill : <== 4. 拥有的被动技能 ItemPassiveSkills : List : <== 5. 物品名 ItemName : string : <== 6. 物品价格 ItemPrice : int : <== 7. 物品购买间隔时间(多次购买同一物品需要等待的时间) ItemPayInteral : float : <== ### 物品格子类(ItemGrid)### 物品格子类是物品的类的包装类型,它表示了在英雄物品栏中的一个个物品格子,其拥有当前物品持有数量等属性,用来处理具体的游戏逻辑。 1. 物品 : Item 2. 持有该物品的数量 count : int 3. 使用该物品的热键 hotKey : keycode ## 商店系统设计 ## ### 功能描述 ### 在MOBA游戏中,有一个通用商店,其中会贩卖一些通用道具和装备,包括药品,新手出门装等等。这个通用商店在屏幕空间的右下角UI位置,点开来就能购买物品。 还有一个商店是在地图上游荡的神秘商人的商店,此商店要靠近神秘商人之后才能开启购买。 1. 商店是可扩展的,其中的商品会增加和减少 2. 商店内物品的数量是有限的,部分物品的数量会再生 3. 商店系统的UI主要显示商店内物品的图标和剩余数量。而物品描述视图则使用另一套UI 4. 商店系统内的物品进行分类,分类有 全部物品、武器、防具、消耗品、其他道具。 5. 当单位资源不足的时候,将无法购买商品,并给予提示信息 6. 商店物品数据由外部JSON文件进行输入 7. 商店逻辑类 StoreLogic 与 商店UI 进行绑定,当商店逻辑类内的物品进行改变时,商店UI也会跟着改变 8. 商店逻辑类有一个用于存储它卖的所有物品的列表ItemList,同时也有一个用于当前展示的物品列表,SoldItemList,UI只显示SoldItemList中的物品。 9. 商店系统有三层View,第一层:用于展示所有物品;第二层:用于展示单个物品及其剩余数量;第三层:用于展示详细的物品的信息,如物品的描述、作用目标等等。 描述一下商店的操作流程: 1. 单位点击屏幕右下角金币图标,打开商店 2. 商店通过DoTweeing显示出来,同时,商店根据默认设置显示商品,也就是默认显示全部商品。 3. 任何一个商品,自身都是ItemPanelView,拥有图标, ### 优化方案 ### 当前方案要用到很多次GameObject.Insitate来初始化ItemPanelView对象,这是非常损耗性能的。下面列举一下我要使用的优化方案: ### 具体实现 ### #### 商品复原功能 #### 商店中展示的是一个个物品格子ItemGrid,对于每一个物品格子,当他的ItemCount为0时,其携带的物品会被置为Null。对于人物物品栏来说,这个设计是合理的,因为确实物品用完了就不能再用了。 但是对于商品的商品栏来说,这个就不合理了,因为商品被买完是会回复的。所以,在这里特地设置一个isCoolDowing状态,用来表示此物品已经被售完,正在冷却回复中。当某物品被卖完的时候(实际上不让它的ItemCount=0,而是让他的的最小值为1),也就是它目前的itemCount为1并且还有单位购买他时,设置它的isCoolDowning状态为true,并在Update中更新它的冷却条 ## 装备合成系统设计 ## 在Moba游戏中,一个比较重要的系统的就是装备合成系统. 装备合成系统描述的是,一个高级装备是由多个低级装备合成而来,当玩家物品栏内有可以合成成高级装备的低级装备时,会自动进行合成. 为了实现装备合成系统,为装备增加两个属性: 1. PrefixEquipments前缀装备 : List< int > : 表示该装备需要什么装备来进行合成,列表的值是需要的装备的ID值 2. SuffixEquipments后缀装备 : List< int > : 表示该装备可以合成出什么装备来,列表的值是可以合成的装备的ID值,需要注意的是后缀装备的值是输入系统自动生成的. 对于游戏策划来说,他只需要设计每个物品的前缀装备就行了,也就是,每个高级物品,他需要的低级物品列表,对于后缀装备列表,我编写一套输入系统根据前缀装备进行自动输入. ### 为什么要有后缀装备这个属性 ### 一个最直接的原因是,实现装备合成的方法,我放在了人物的GetItem方法中,也就是说,当玩家获得一件装备时,系统会首先观察这个新获得的装备是否可以和目前装备栏的现有装备进行合成出高级装备. 这个判断的过程也就是查找的过程,如果没有后缀装备这个属性,要遍历整个物品表来查找是否可以合成. 当有后缀装备属性时,只需遍历后缀装备列表,查看是否物品栏内有装备可以一起合成这个高级装备. ## 状态系统设计 ## ### 功能描述 ### 状态是一种持续影响某一物体的表现,最典型的状态比如:中毒、伤害加深。 当单位受到中毒状态时,他会每隔一段时间受到一定比例的伤害。 而当单位受到伤害加深状态时,他会在遭受攻击时,受到的伤害加深。 更为特殊的状态,当单位拥有某一种特殊状态时,它的攻击会带有某种特效之类的。 从上面的描述中,可以看出状态有以下特点: 1. 持续性的影响某一单位,当时间超过某一限度,此状态将会消失 2. 除了持续性的影响外,还可能带有某些特殊效果,这些特殊效果可能会在状态持有者 攻击时、遭受伤害时等情况下触发 ### 实现思路 ### 根据功能描述,可以把状态看成是一个在Update状态不停作用单位的一种Mono类。 但是,对于状态拥有的某些特殊功能,仅仅是上面这些还不够。对于状态的特殊效果,可以看出它的描述特别像被动技能。 在这里,可以将状态定义为: 1. 在Update状态下不停作用物体 2. 拥有一系列被动技能作用物体 ### 状态(BattleState)属性 ### 1. 状态名 stateName : string : <== 2. 状态描述 description : string : <== 3. 状态持续时间(单位:秒)(为0表示状态永久存在) duration : float : <== 4. 状态拥有的一系列被动技能 statePassiveSkills : List : <== 5. 状态持有者会产生怎样的特效 stateHolderEffect : GameObject : <== ### 状态(BattleState)重要方法 ### 1. public virtual OnEnter(CharacterMono stateHolder) 2. public virtual OnUpdate(CharacterMono stateHolder) 3. public virtual OnExit(CharacterMono stateHolder) 状态的运行流程如下: 1. 当某一单位被施加了某一状态,首先进入该状态的OnEnter方法 2. 在每一帧更新时,进入状态的OnUpdate方法,同时,在OnUpdate方法中判断状态持续时间是否到头,如果持续时间已经过了,那么自动执行OnExit方法 3. 状态消失时,自动执行OnExit方法 ## 单位 ## 需要明确的是,每一个单位都在外部由编辑器提前设定。 ### 普通单位Character ### 1. 属性 1. 血量 HP:int : <== 2. 魔法值 Mp:int : <== 3. 最大血量 maxHp:int : <== 4. 最大魔法值 maxMp:int : <== 5. 名称 name:string : <== 6. 攻击距离 attackDistance:float : <== 7. 该单位拥有的所有技能 baseSkills:List< BaseSkill > : <== 8. 该单位拥有的所有主动技能 activeSkills : List< ActiveSkill > 9. 该单位拥有的所有被动技能 passiveSkills : List< PassiveSkill > 10. 攻击力 attack : int : <== 11. 攻击类型 attackType : AttackType : <== 12. 攻击间隔(以秒为单位,每经过一段攻击间隔,便进行攻击一次) attackSpeed : float : <== 12. 防御力 defense : int : <== 13. 防御类型 defenseType : DefenseType : <== 14. 移动速度 movingSpeed : int : <== 15. 转身速度 turningSpeed : int : <== 16. 投射物 projectile : Projectile : <== 17. 等级 level : int : <== 18. 回血速度 restoreHpSpeed : float : <== 19. 回魔速度 restoreMpSpeed : float : <== 20. 是否可被攻击(无敌) canBeAttacked : boolean : <== 21. 单位类型 unitType : UnitType : <== 22. 单位阵营 unityFaction : UnitFaction : <== 23. 单位被杀死后将提供给英雄单位多少经验 supportExp : int : <== 24. 单位被杀死后将提供给玩家单位多少金钱 supportMoney : int : <== --- ### 英雄单位 Hero < Character ### 1. 属性 1. 力量 forcePower : float : <== 2. 力量成长 forcePowerGrowthPoint : float : <== 2. 敏捷 agilePower : float : <== 3. 敏捷成长 agilePowerGrowthPoint : float : <== 3. 智力 intelligencePower : int : <== 4. 智力成长 intelligenceGrowthPoint : float : <== 4. 技能点 skillPoint : int : <== 5. 技能点成长 skillPointGrowthPoint : int : <== 5. 经验值 exp : int 6. 经验值因子(每次升级所需经验值关联系数) expfactor : float : <== 7. 升级所需经验值(指第0级升到第一级所需经验) needExp : int : <== ### 英雄单位相比普通单位有什么区别 ### 1. 更多的属性 2. 杀死怪物后获得经验 3. 当经验到达阈值,升级 4. 升级后,获得技能点,同时智力、敏捷、力量等进行成长 5. 拥有物品栏 6. 使用技能点可以学习技能 ### 英雄单位的特殊属性对基本属性的作用 ### 英雄单位相对于普通单位,有以下三点特殊属性: 1. 力量 2. 敏捷 3. 智力 这三点属性的增加会导致单位的基本属性增加(攻击力、防御力、闪避率、hp、mp等等),下面给出详细叙述。 力量:   每点增加x点HP   每点增加x点hp回复速度   每点增加x点攻击力(主属性加成) 敏捷:   每点增加x点防御力   每点增加x点攻击速度   每点增加x点攻击力(主属性加成) 智力:   每点增加x点MP    每点增加x点MP回复速度   每点增加x点攻击力(主属性加成) --- ### 投射物Projectile ### 投射物是一个比较特殊的单位,在一些技能和单位的远程攻击里面出现,投射物一般有一个目标位置。 #### 投射物的伤害计算 #### 要明确的一点逻辑是,投射物本身是不具有伤害的,具有伤害的是发出投射物的单位,所有投射物都是由单位(继承自CharacterMono的所有子类)产生的,由该CharacterMono类指定伤害。 **总结一下**,也就是,由CharaacterMono类监听投射物的事件,**当投射物到达目标地点后**,触发伤害事件,**调用CharacterMono的GetDamage的方法对敌人造成伤害**。 对于一些攻击,如普通攻击等,需要计算敌我的防御值、攻击值,才能产生伤害,这些都是由Character类做的事情,投射物本身不进行任何伤害计算,他只是一个传递者。 #### 投射物属性 #### 1. 目标位置 targetPosition : vector3 2. 移动速度 speed : float 3. 此次运动是否是弧线轨迹 isArcMotion : bool 4. 弧线运动中投射物上升的高度 riseHeight : float 5. 弧线运动中投射物初始角度(基于竖直平面) angle : float 6. 弧线运动中投射物结束角度(基于竖直平面) endAngle : float 7. 投射物到达指定地点时产生的特效 targetPositionEffect : GameObject 8. 投射物击中敌人时,在敌人身上产生的特效 targetEnemryEffect : GameObject #### 投射物的飞行轨迹 #### 对于一个投射物来说,他有两种飞行轨迹:       一种是直线的,也就是直接从某处平移到某处,这种飞行轨迹容易实现,只需让投射物对着目标地点进行均匀的平移就可以了       一种是有弧线的,这种飞行轨迹一般用于弓箭、炮弹、飞行生物降落的攻击等投射物上,这种飞行轨迹较难实现,因为对于弓箭这种投射物,它的旋转角度也会发生改变,对于从高处降下,基于DoTween插件可以较为容易的完成这项功能。 #### 投射物飞行轨迹实现 为了统一两种飞行轨迹,可以直接都使用DoTween插件中的DOLocalPath方法进行计算,对于弧线轨迹中的旋转基于DoRotate来进行旋转(计算好duration)。 ## 玩家 ## ### 描述 ### ## 音频管理与特效管理 ## ### 音频管理 ### 在游戏中有很多地方需要播放音乐,我将播放音乐的单位(或者情况)分为场景音频、单位音频和UI音频。所有音频的接受者都是摄像机。场景音频、UI音频为2D音频,单位音频为3D音频。场景音频、UI音频的AudioSource在摄像机上,而单位音频的AudioSource在单位身上。 #### 场景音频 #### 场景音频主要是整个游戏的BGM播放,由摄像机作为音频的播放者。需要注意的场景BGM是轮播的,也就是说,需要有一个脚本来管理轮播的BGM,以及决定是否轮播以及什么时候播放下一首BGM。 #### 单位音频 #### 单位是游戏中主要发声对象,而单位在游戏中我分为两种,一种是生命实体(泛指一切拥有CHaracterMono组件的GameObject对象),一种是特效,特效粒子在后面进行解释,现在先看生命实体的发声机制。 他有可能在以下情况播放音频: 1. 攻击时,武器发出的撞击声 2. 死亡时,倒地音效 3. 施法时特殊的音效 4. 单位被玩家进行命令时,发出的语音 .etc 两种方法设置单位音频,一是为单位设置声音模块,基于观察者模式在单位特定事件触发(攻击、死亡、施法)时播放音频,二是基于反射对各个方法进行拦截,使用AOP的方法在单位执行特定方法(Attack,Spell)时执行。 因为基于反射的方法性能低下,同时基于AOP的方法只能在方法执行前后对音频进行播放,而不能在方法执行时进行播放(这样的话,可能会造成单位闪避了,但还是播放了单位受伤的音频),所以**放弃方案2**,**启用方案1**。 **下面简单介绍一下方案一**: 单位基于一个声音模块CharacterAudio来对单位播放的音频进行管理,CharacterAudio在初始化时,根据CharacterModel设置的基本属性(攻击时使用什么音效,死亡时使用什么音效,此处使用的是这些音效的地址),对Resource(也可以使用AssetsBundle)进行读取,根据这些音效的地址,读取一个个实质的AudioClip对象进内存,并以字典的形式保存到单位身上(CharacterMono)。 同时,声音模块拥有一个绑定方法Bind,在初始化时,声音模块订阅单位身上的事件,当特定事件触发(攻击、死亡),播放特定的声音。 #### UI音频 #### 在玩家对对应的UI进行点击的时候,会有对应的音频进行播放,大致有以下事件,下面简单列举一下: 1. 点击学习技能的按钮时,播放音频 2. 弹出提示窗口时,播放对应音频 3. 购买商店物品时,播放音频 4. 弹出警告窗口(警告玩家不够金钱或不够mp放技能等等),播放音频 ### 特效管理 ### 在游戏中,特效基本为GameObject对象,因为一个GameObject对象可能同时包含多个粒子特效系统,所以在创建一个特效时,一般我都以GameObject对象为初始化的模板。 在游戏中,出现特效的时机一般为以下几点: 1. 单位攻击时,自身产生的特效以及敌方产生的特效 2. 当一个技能被释放时,施法者和目标(单位或地点)产生的特效 一个单位出现特效的地点有以下几点(根据单位身上的collider来判断位置): 1. 头部 2. 身体部位 3. 脚部 对于特效出现的时机,我目前使用的方法是将特效出现的代码 **耦合** 到技能类BaseSkill类中,在技能类的Execute方法中,负责在双方身上创建特效对象。对于特效对象的创建,使用临时对象工厂。 一个特效对象,是有其存在时间(生命周期)的,一般来说分为以下几种: 1. 状态型持久特效(如:由被动技能、光环技能产生的特效对象,这种特效对象将持久的依附在单位身上,当单位失去状态时,状态消失) 2. 持续一段时间(典型例子:中毒、流血状态,当处于流血状态,给予单位一个流血特效,流血特效将会持续到单位死亡或是状态消失) 3. 播放完粒子特效就自动消失(典型例子:暴击技能,对单位造成一个暴击,播放一个特效,播放完毕,粒子特效自动消失) #### 如何管理特效对象的生命周期 #### 一开始的思路是通过一个临时对象工厂(TransientObjectFactory)来负责创建临时对象并管理临时对象的生命周期,但是,因为临时对象最常用的一个方法--在N秒后自动消失,播放完自动消失等特性,工厂如果不开辟协程,将很难实现这种效果。但是没开辟一个协程,都会造成内存的浪费,故这里放弃这个方案。 我采用的方案是,创建一个用于管理特效对象生命周期的MonoBehavior组件,还是用工厂模式来创建粒子特效对象,但是,这里的工厂模式不负责管理对象的生命周期,只负责创建,在创建时,工厂对象自动为这个GameObject对象**AddComponent一个EffectsLifeCycle组件类**。 EffectsLifeCycle组件在Update(或FixedUpdate)方法中检查对象消失条件,一旦满足对象消失条件(如:经历一段时间、播放完粒子特效),就Destory目标对象。 对于状态型的持久型特效对象,由状态对这些特效对象进行管理。也就是说,当进入某个状态时,自动产生某些例子了特效对象。当离开状态时,自动Destroy这些粒子特效对象。 下面列举一下各种特效对象生命周期的管理: 1. 播放完粒子特效就自动消失的: 使用EffectsLifeCycle组件进行管理 2. 持续一段时间的: 使用EffectsLifeCycle组件进行管理 3. 状态型特效: 使用EffectsLifeCycle组件进行管理,与前两个不同的是,在明确此次创建的是状态型特效时,EffectsLifeCycle将在粒子生命周期的开始,订阅状态消失事件,当检测到某次状态消失事件消失的状态是本状态型特效依附的状态时,删除粒子对象,并取消订阅。 ## 战斗系统规则 ## ### 判定伤害 ### 1. 对于普通攻击来说,只有当一个人物动作完整的播放完攻击动画时,才对对面给予伤害,最终伤害判定根据被攻击者和攻击者的距离来判定(这里针对近战攻击),距离每超过攻击者的攻击范围的10%,被攻击者的闪避率增加10%。 2. 所有伤害判定均由"**伤害类**"来进行判定,所有普通攻击,伤害技能....等等,最终都会产生伤害类,由伤害类来判断最终给予目标的伤害。 3. 对于拥有投射物的单位,其攻击逻辑与直接造成伤害的不一样,当攻击动画播放完毕后,会自动在发射点创建一个投射物向敌人进行移动,当投射物碰到敌人时,造成伤害。 ### 技能 ### 1. 所有技能都有一个基类,基类Skill包含了技能的基本特性,如:造成的伤害,出现的状态等等,拥有一个通用的用于计算技能伤害的方法,该方法将会产生一个伤害类,并执行此伤害类。 2. 技能细分下来,分为主动技能和被动技能,主动技能中分为指向性技能、原地释放技能等等,这些分为的种类,都各自写一个类。 3. 对于技能的编辑,到时可以撸一个类似Rm的技能编辑窗口,这个窗口最终将编辑好的技能保存为Json、CSV、sqlite等数据集合 # 编辑器设计 编辑器用来对游戏中常见的数据进行编辑,如单位属性、技能属性等等。编辑器可以让非程序员完全不用与数据存储格式打交道(也就是说,使用编辑器的人不用关心到底是用哪种格式来存数据,如txt/json/xml/sqlite)。 从而可以让策划快速验证一些东西,比如某些数值公式、自定义编辑的技能的效果等待。 好的编辑器,要做的事情只有两点: 1. 编辑数据 2. 展示数据 第一点,必须要完全没有学习成本,并且可以大量减少策划和程序工作量。也就是说,对于数据和公式的编辑,是不复杂的(就算是复杂的,最后也要简化成基本类型的数据填写) 第二点,必须要在策划编辑完数据后(或者编写的同时)展示数据编辑后的效果。举个例子,对于技能编辑器来说,策划必须要知道刚才编辑的技能特效在游戏中具体是什么样的,最后编辑好的技能在游戏中释放后具体是怎么样的。 ## 编辑器技术选型 对于这类游戏数据编辑器,一般来说有以下几种开发手段。 编辑器开发方法 | 优点 | 缺点 --------|----|--- Unity自带扩展编辑器 | 1.可以同时打开多个窗口;2.可以直接使用当前程序集(由此可以利用反射技术);3.不污染Runtime代码;4.方便直接序列化数据到Prefab和ScriptObject | 1.布局不不方便;2.编写数据展示功能较复杂 UGUI | 1.布局可视化,所见即所得;2.数据展示方便,如粒子特效、技能的运行等均可方便展示; | 1.不方便更改,每次改完编辑器的代码要重新编译生成exe文件给策划 外部编辑器(即非内部Unity编写) | 1.可定制型强;2.更符合一般编辑器的制作思路; | 1.拿不到Unity项目的运行时信息,也就是说,无法依靠反射来获得项目的信息,这意味着每次Unity项目代码更改,都要重新编写此编辑器 在本项目中,我选择使用UGUI来做游戏数据编辑器。 使用UGUI来做对于数据的编辑和展示基本上是最方便的,即利于程序员布局,又利于策划观察效果。 _**关闭弹窗**_ 关闭弹窗是编辑器的一个十分重要的部分。 对于策划来说,他很有可能不小心关闭编辑器,这时需要编辑器主动提醒使用者对数据进行保存。 在本游戏中我使用JSON为数据的存储类型,也就是说,当使用者无意中关闭技能编辑器时,会主动提醒使用者对数据进行保存再关闭,此时可以点选保存数据后关闭。将数据打包成JSON格式保存到一个地方。 **需要额外注意的点:** 1. 编辑器所能读取的数据(图片、prefab、txt文字、lua文件、其他数据JSON文件)均由一个外部AssetsBundle来管理,也就是说,所有的图集、特效Prefab都是从这个AssetsBundle上获取。 ## 编辑器草图 整体视图(即选项卡视图): ![Avater](readmeImage/选项卡.png) 图片、模型、特效选择器 ![Avater](readmeImage/图片、特效、模型选择器.png) ## 技能编辑器 明确技能编辑器要编辑的属性: 1. 技能类型(即当前修改、增加、删除的技能的技能类型) 2. 此技能开放的接口属性(如伤害、图标),通过技能类型反射得到 要展示的效果: 1. 各类图片在编辑器中正常显示 2. 各类特效对象在编辑器中正常显示 3. 允许在编辑器中展示技能运行后的效果(方便策划观察技能组中不同技能排列组合的实现效果) # AI设计 # ### ● NPC部分 ### --- ### 守卫型NPC行动分析(如:防守塔,野区野怪) ### 1. 基本操作有:攻击、移动、返回原地点 2. 攻击:当此NPC周围出现敌人时,自动对此敌人进行攻击,在这里分为两种情况。 1. 对于有移动能力的守卫NPC,将对目标进行追击,直到目标跑出视野,返回原区域 2. 对于没有移动能力的守卫NPC,则对目标进行攻击,当目标跑出攻击区域,自动停止攻击 3. 移动:这里针对有移动能力的NPC,当此NPC周围出现敌人时,对该敌人进行追击。 4. 返回原地:当此NPC周围没有敌人时,自动返回原本的根据地。 ### 进攻型普通AI行动分析(如:固定出兵攻打敌方基地的小兵) ### 1. 基本操作:攻击、移动 2. 行为描述: 1. 小兵从固定地点产出 2. 小兵按照 3塔、2塔、高地塔、基地的顺序对敌方进行进攻 3. 小兵按照固定的路线向地方前进 4. 当小兵在移动中发现敌人时,将敌人消灭后继续前进 5. 小兵毁灭3塔后,向2塔前进,毁灭2塔后,向1塔前进,毁灭1塔后,向基地前进,基地毁灭,阵营胜利。 3. 逻辑分析: (伪码) if(周围有敌人): 消灭周围敌人 else: 进攻3、2、1塔、基地 ### 普通AI选择敌人策略 通用型AI选择敌人时遵循权值策略,即给周围所有敌人打分,并对分数最高的敌人进行攻击。每次执行策略至少持续1s后才会重新进行打分。 分数参考(100分制): 单位剩余生命值(优先攻击声明值少的) - 30% 单位魔法值(优先攻击魔法值多的) - 20% 单位是英雄单位(优先攻击英雄单位) - 20% 单位距离最近(优先攻击距离近的单位)- 30% ### 英雄AI设计 ### #### AI所能获得的知识 #### 下面列出英雄AI在游戏过程中所能获得的知识: 1. 敌方所有英雄的位置 2. 周围所有敌人和盟友的位置和状态信息 3. 自身 生命值/魔力值/技能冷却程度/所拥有的技能 4. 目前未冷却技能在1s内所能造成的最大伤害 5. 兵线危机值(即三路兵线哪边的兵线较为危险)(危机值的计算根据该条兵线内敌方单位数量-我方单位数量得来,每个英雄单位=5个小兵单位) 6. 该AI所在的兵线 7. 该AI所在兵线离基地距离最远的小兵所在路径点下标 #### AI所能做的操作 #### 1. 推兵线,根据兵线危机值,英雄前往危机值最高的兵线. 2. 换线,在某条兵线的危机值高于某一阈值时,进行换线操作. 3. 单体战术AI,面对普通单位时,有一套战斗策略,面对英雄单位时,有另一套战斗策略. 4. 补兵,反补 #### AI行为树参考 #### 来自: https://cloud.tencent.com/developer/article/1146677?fromSource=waitui ### 处理人物死亡逻辑 ### 当人物HP降为0时,人物死亡。 进入死亡状态的单位,停止目前一切动作,播放死亡动画,同时设置isDying为true,表示人物正在垂死状态中,当死亡动画播放完毕后,Destory该单位。(这个Destory可以有多种意思,在有对象池的情况下,这个Destory可能仅仅只是收回对象回池内而已) ### ● 玩家部分 ### --- ### 人物行动分析 ### 1. 基本人物操作有:攻击、移动、施法、吃药、换装备。 2. 移动:鼠标对某处点击右键,当目标不是敌人时,进行移动操作。移动开始时,播放移动动画,显示移动特效,移动结束后,移动动画结束播放。 3. 攻击:当鼠标对某处点击右键且目标是敌人时,进行攻击操作。 1. 攻击操作准备开始时,首先判断主角当前位置和敌人的距离是否是可以攻击的距离,如果不可以攻击,那就移动到目标敌人的位置上进行攻击,如果可以攻击,那么进行攻击操作。 2. 当追击敌人的时候,如果敌人跑出视线范围,那么就自动放弃追击。否则会一直穷追不舍。 2. 攻击开始时,播放角色攻击动画,此时进行逻辑判断,当角色攻击动画完成后(这里有近战及远程攻击的区别),对敌人进行伤害处理。 4. 施法:当按下某个未在冷却中且为主动技能的法术时,进行施法操作。(暂时只考虑指向敌人型技能和原地释放技能) 1. 施法操作准备开始时,鼠标变换图片,变成一个带有指向性的攻击图标。 2. 鼠标指针图标变换完成后,鼠标右键单击敌人,开始施法。 3. 施法开始时,判断是否有施法时间 。 1. 有的话,播放持续施法动画,持续施法动画播放结束后,进入2。 2. 没有的话,播放施法动画,判断施法动画是否结束,当施法动画结束后,播放我方特效动画以及敌方特效动画,对伤害进行结算。(此处伤害结算包含了立即型伤害以及放一个触发器去碰敌人然后使敌人受到伤害) 5. 吃药:可以将药品理解为消耗性技能,其判断与施法大体是相同的,同样是判断按键,同样是吃药动画、伤害结算等等,唯一不同的地方是,吃药是可以把要吃完的。 6. 换装备:暂不实现。 ### 尝试使用行为树 ###   写到一半,复杂度骤增,放弃....................... --- ### 尝试使用状态机 ### #### 状态机注意事项 #### #### 施法状态的编写 #### 施法考虑到技能有指向型技能(指向型技能又分为有必须单击敌人才能释放的单指向型技能和按照一定范围释放【类似WOW里面的"暴风雪"】的技能)与原地释放技能的区别. 所以在anyState的状态更新中判断用户是否按下了释放技能的按键(关于技能冷却,是否够mp放技能,都可以放到anyState的OnUpdate里面判断).当用户按下释放技能的按键且有足够条件释放技能时,设置CharacterMono中的prePareSkill技能类且使isPrePareUseSkill为True. 设置anyState状态状态的Transition类,该Transition定义了从anyState到Spell状态的规则.规则如下: 1. 当isPrePareUseSkill为True且释放的技能为原地释放技能时,进入Spell状态,设置isImmediatelySpell为True 2. 当释放的技能为指向型技能时(暂时不划分单指向型技能和范围指向技能),当玩家用鼠标单击敌人的时候,进入Spell状态,设置isImmediatelySpell为False #### 施法状态的转移 #### 需要注意的是,施放技能状态和其他状态最大的不同是,施放技能状态会自动结束,并回到Idle状态. #### 状态机图片 #### ![Avater](readmeImage/stateMachine.png) # 战争迷雾设计 # ## 效果演示 ![Avater](readmeImage/fovFog.png) ## 原理 ## 生成一张全黑的贴图,在贴图上挖洞造成战争迷雾的效果. 战争迷雾分为逻辑层和表示层,表示层用于生成遮罩在整个地图上的贴图,逻辑层负责管理每个单位的逻辑可见状态。 在战争迷雾中,有两类物体。 第一类物体,我称为表示层物体,该物体会通过他的视野来更新战争迷雾的贴图,从而增加迷雾的可见范围。 在MOBA游戏中,对于表示层物体,他应该是跟主玩家(MainPlayer)的阵营一样,换句话说,只有跟主玩家阵营一样的单位,才能在表示层中消除迷雾,对于敌方单位(非主玩家单位阵营)的物体,不会对战争迷雾的贴图进行任何更新。 第二类物体,我称为逻辑层物体,逻辑层的物体会每时每刻更新他们的逻辑可见状态(用于在代码中判断某个物体是否在战争迷雾中可见),通过他的功能描述,可以分析的到,逻辑层物体是包含本游戏的所有带有视野的物体,而他们的**可见状态(IsVisble)** 则是相对于主玩家(MainPlayer)来说的,如果对于主玩家来说,他们是不可见的,那么IsVisible为False。 ## 问题 ## 1. 贴图怎么生成 2. 什么时候更新贴图 3. 带有视野的单位怎么更新贴图 4. 多个带有视野的单位怎么互相作用 5. 如何将生成好的贴图渲染到屏幕上 ## 解决方案 ## 1. to do~ # 鼠标指针管理 ## 功能描述 在MOBA游戏里面,鼠标指针会因为某些游戏逻辑而产生变化。比如 1. 在默认情况下是一个普通鼠标图片 2. 当鼠标放在友方/敌方单位时,指针图片更改,当鼠标移开时,指针回到默认状态 3. 当单位释放技能/拾取物品时,鼠标指针也应该随之改变,当单位取消释放技能/拾取物品时,指针回到默认状态。 ## 实现思路 1. 对于功能2,在Update下,每帧使用射线检测当前鼠标指向的单位是不是友军或敌军单位,如果是,那么鼠标指针变换。每帧开始时,将鼠标指针置为默认状态。 2. 对于功能3,在update下,每帧检查单位是否处于释放技能/拾取状态下,如果是,那么鼠标指针变换。每帧开始时,将鼠标指针置为默认状态。 # UI设计 # ## 各级UI依赖关系 ![Avater](readmeImage/MOBA各UI依赖关系.png) ### 使用观察者模式进行解耦 大部分UI跟CharacterMono单位类强相关,为了不让他们互相引用互相依赖,这里用事件(event)对他们进行解耦。 即单位身上每一个属性的改变,都是一个事件的发生,比如HP的变化、物品、状态的增加与减少等,这时,UI就可以监控(或者说订阅)这些事件,当事件发生时,UI自动更新它的表现形式(如HP变化引起生命条的宽度变化)。 ### 使用中介者模式进行解耦 警告UI是一个很特殊的UI,当玩家试图尝试某些无法完成的操作时,警告UI有必要提醒玩家这个操作为什么不能完成。 这些操作包含的部分十分广泛,比如(仅列举一部分): 1. 准备放技能,发现没有Mp了 2. 准备购买物品,发现没有钱了 3. 准备攻击一个目标,发现该单位是无敌的 4. 准备治疗一个单位,发现该单位是敌人 如果按传统的做法,那警告UI的Show方法就会散落在整个代码架构的各个角落,emmm,这是个很恐怖的事,也就是说几乎游戏里全部对象都引用了一份警告UI。只要警告UI出现了一点错误,这整个游戏就跑不动了,也就是,蜘蛛网式的引用。 这里使用中介者模式解耦,玩家控制的单位做什么操作,都会产生一个事件,当事件发生时,事件发生的对象发送一个消息给中介者,中介者将这个消息传递给对该消息感兴趣的对象(就是警告UI了) 使用这种做法,警告UI和各个对象不需要互相引用,只需要保持对中介者的引用就好了。(然而我这里根本没有中介者,事件发生就直接传到感兴趣的对象那了) ## 拖拽赋值组件与Find迷思 下面使用注解的方法似乎并不是更好的。看了知乎[【Unity3d 拖拽赋值组件与通过Find赋值组件的优点与缺点?】](https://www.zhihu.com/question/66451128)这个问题的讨论,思考了一下。 实际上,注解赋值组件似乎并不是一个好的选择,注解这个方法实际上只是将拖拽的复杂度转移到了代码中,同时注解还带来了一个新的问题,UI结构不能发生太大的变化,不然每个UI组件的路径都要重新进行编写。通过代码赋值组件的优点大概就是安全性高把,组件之间的引用不会像拖拽那样容易丢失。 ### 使用注解(C#中的Attribute)来避免在编辑器中UI对其他物体的直接引用 直接使用Unity编辑器的窗口来对各级UI引用是一种比较坑爹的行为,下面图片就是一个错误示范。 ![Avater](readmeImage/UI引用错误示范.png) 可以看到一个具体的UI视图(Prefab)里需要引用许多UI控件(Text、Image等),如果直接在编辑器里对他们进行引用是不方便维护的。 为什么呢?可以想象,如果中途有几个UI需要进行添加、删除,又要手动将他们从编辑器的引用中拖拽。这是极其不方便的。还有一点就是,如果Prefab出错或丢失了,那么之前在编辑器拖好的属性又要全部重新设置,这是很让人崩溃的。 这里我采用**注解+反射**的方法来解决这个问题。将手动拖拽转变为由代码来管理这些引用关系,即由代码来添加引用,而不是在编辑器里设置。 主要的原理是,在每个UI控件属性上注解一个他们的Path,在代码中,根据path和transform.find来查找这些UI控件并自动引用,从而避免手动设置引用的隐患。 所有UI上挂载的MonoBehavior脚本里的需要引用的UI控件属性都要通过UIComponentAttribute来进行注解。 简单举个例子,如下所示是一个人物属性视图,用于显示MOBA游戏中人物的等级,名字,攻击力等属性。 public class AvatarView : MonoBehaviour { //========================== // 此View管理的UI控件 // 英雄头像 [UIComponentAttribute(path="AvaterPanel/AvaterImage")] public RawImage AvatarImage; // 名字 [UIComponentAttribute(path="NameText")] public Text heroNameText; // 等级 [UIComponentAttribute(path="LevelPanel/LevelText")] public Text levelText; .................. } 可以看到所有关于UGUI的控件属性(即需要在编辑器拖拽的)都加上了注解。在代码中,即通过这些注解的Path属性来对UI控件进行查找,主要是在OnStart方法中进行自动引用。 ## 自适应问题 ## ### 问题提出 ### 自适应算是UI设计的时候一个小难点了,主要问题在于,显示技能、物品的提示窗口 **大小** 是不固定的,他们的大小跟他们自身显示的信息多寡有关,当显示的信息多时,窗口大,当显示的信息少时,窗口小。 ### 问题解决 ### 对于这种自适应问题,通常解决方法是使用Vertical Layout Group或者 Horizontal Layout Group组件,对于结构并不复杂的UI控件(即不需要布局),可以使用ContentSizeFitter控件来进行自适应。 ### Vertical Layout Group 与 Horizontal Layout Group自适应的原理 LayoutGroup多用来对复杂的UI进行布局(比如人物状态视图,或者类魔兽 Or RM的数据编辑器) ## 各类UI设计 ### BattleStatusView(状态视图) #### 功能描述 1. 显示所有状态的剩余时间 2. 显示所有状态的图标 3. 当鼠标移到某一个状态上面,可以显示状态的详细信息 #### 实现 对于第1点功能,说明这个视图不能只靠数据绑定来完成,必须要有在**Update**方法下运行的计算剩余时间的代码。 ### 物品视图 #### 可执行的操作 1. 显示英雄身上所有物品,并显示这些物品的冷却情况和数量 2. 对对应物品单击左键或键盘快捷键可以使用该物品 3. 鼠标放在物品上会显示tips提示视图 4. 对对应物品单击右键可以将物品拿起,拿起物品后。 1. 单击鼠标右键后取消拿起 2. 对地面单击左键后,将物品放置地面 3. 对己方英雄单击左键后,将物品给予己方英雄 4. 对物品格子单击左键后,将当前物品放到对应的物品格子中去 #### 实现 ## UGUI控件扩写 ### 指定输入类型InputField控件 #### 功能要求 1. 自动判断本次输入是否是指定类型,如果不是,无视本次输入(三种基本类型,整型、字符串、浮点型) 2. 自动根据字段名、输入数据长度扩充控件长度宽度,当减少数据时,同样对控件进行自适应 3. 使用Value属性获得该控件的值,值类型即为规定输入类型 ### 显示图片选择框 #### 功能要求 1. 显示类Unity自带的图片选择器(图源从指定的AssetBundle处读取)