本课程是 Unity 3D 系列教程,目标是带领读者搭建一个商业游戏的网络架构设计,该架构设计是游戏的核心技术,将采用 Unity 2017.2 最新版本作为开发工具。内容分为 UI 架构、技能架构、服务器和网络同步四大部分,共 13 篇文章。
认真读完本系列文章之后,将会深入理解架构的设计,具备独立搭建网络游戏框架的能力,并在此基础上可以独立开发一款网络游戏。
姜雪伟,从事 IT 行业15年,现担任创业公司技术合伙人。著作有:《手把手教你架构 3D 游戏引擎》、《Unity 3D 实战核心技术详解》,《Cocos2d-x 3.x 图形学渲染技术讲解》等,参与或主导过十多款网络游戏研发。
随着 Stream、TapTap 等游戏平台的崛起,越来越多的网络游戏在此平台投放,而且很多新发布的游戏收入都颇丰,这些发布的游戏很多都是几个人开发完成的,而且开发周期都比较短,如何才能快速开发网络游戏?一个比较好的游戏框架是非常必要的。另外,这些平台的崛起,对于独立游戏开发者来说,也是一个非常好的机会,换句话说独立开发者的春天又来了,当然对于那些想从事游戏开发或者说已经在这个行业从事游戏开发的人也是一个机会。
现在游戏不只限于抄袭了,更强调创新,只要有好的创意,再加上一个比较好的游戏框架,几个志道同合的小伙伴就可以开发一款网络游戏。在国外有很多这方面的案例,几个人在不同的地方,一起开发一款游戏。而在国内很多普通程序员在游戏公司估计只是从事某项单一的逻辑功能编写,对整体架构设计并不是很了解,即使自己有好的想法局限于自己的能力估计也是很难做出一款游戏,在游戏公司很少有人会教你架构设计,而且对于开发者来说,要么只会客户端,要么只会服务器,非常少的人同时精通二者,这也困扰着那些想自己做游戏的开发者。本课程正是基于解决这些困扰程序员的问题,提出了一种基于网络服务器的架构设计,让程序员一个人可以同时进行客户端和服务器的网络游戏的开发,这样再加上美术和策划就可以搞定一款网络游戏。
搭建游戏框架首先要搞清楚什么是框架?其实搭建框架的主要目的是便于游戏逻辑的编写,这样非常有利于开发者快速的开发游戏,框架的核心思想是模块之间的耦合性要降低。那我们先搞清楚游戏框架中主要包括哪些技术点,从大的方面说,每款游戏都有自己的 UI 系统、角色系统、技能系统、网络系统等等,往小的方面说就是编码的细节——每个类的编写。下面就把游戏中的几个核心系统的架构设计思想逐步介绍给读者,架构设计没有好坏之分,用着方便就可以,在这里就当是抛砖引玉,读者也可以在此基础上去扩展,去重新编写架构。这样本篇教程的目的就达到了。
先介绍 UI 系统,这个是老生常谈的,UI 架构常用的设计模式是 MVC。读者应该对 MVC 都比较了解,原理就不介绍了,可以去网上查阅。下面讲下 MVC 模式如何在 UI 系统中使用?先看下面这幅架构图:
我们就围绕着这幅图给读者介绍模块设计。
首先,要做到 UI 资源和代码逻辑的分离,因为 UI 资源是经常更换的,如果二者不分离,很容易在更换资源时出现各种各样的脚本丢失以及资源和代码逻辑对应不上问题,这个对于程序来说必须要避免的,程序员不应该把时间都浪费在这些事情上面。
其次,逻辑代码之间的耦合性要降低,降低耦合性的方法通过事件的方式进行处理,很多程序使用 SendMessage 这种 Unity 自带的消息发送机制,其实它是非常消耗 CPU 的,为了优化这些,我们会自己封装事件机制。
以上两点是指导我们做架构的指导纲领,不论怎么设计最好围绕二者进行。
接下来介绍 UI 架构搭建的各个逻辑模块,上图中显示的窗体模块并不全面,游戏中的窗体是非常多的,在此以登录窗体和英雄窗体为例进行说明:Loginwindow 和 HeroWindow 它们是负责显示的,也就是说,它对应具体的窗体逻辑,它对应的 MVC 模式中的 V,相当于 View 显示,该模块是不继承 Mono 的,也就是不挂接任何 UI 对象。LoginCtrl、HeroCtrl 模块相当于 MVC 中的 C,Control 控制,用于操作 LoginWindow,HeroWindow 所对应的 UI 窗体,比如用于控制不同窗体的显示、隐藏、删除等等操作,在图中没有列出 MVC 中的 Model 模块,这个模块主要是用于网络消息数据的接收,也可以通过文本文件直接赋值的,它可以使用列表进行存储,相对来说用处并不是不可替代的。
游戏中存在的窗体是非常多的,这么多窗体,如果不同的开发者写逻辑,会搞的很多,不利于统一管理。由此需要一个类 WindowManager 管理类进行统一注册管理各个窗体类模块,这种处理方式也就我们经常说的工厂模式。
另外,窗体之间是经常会进行不同的切换,这些切换也可以对它们进行流程管理,因为窗体之间的切换是一种固定的流程。既然经常转换,我们不免会想到状态机用于处理这种流程。在此,引入了状态机进行统一管理不同窗体状态的变换。各个模块之间的耦合性也是要重点考虑的问题,在此采用了自己封装的事件机制进行解耦合。
具体实现逻辑如下,每个窗体对应自己的类,以登录 UI 为例进行说明,每个 UI 都是一个 Window 窗体对象,它对应着 Loginwindow 类、LoginCtrl 类、LoginState 类。其他的窗体类似,而这些类都不继承 Mono 也就是说不挂接到任何 UI 窗体对象上,这样,彻底实现了资源和代码的分离,UI 系统思想设计完成,接下来再介绍技能模块和角色系统的架构设计。
技能模块在游戏中的表现非常重要,也是常见的,在实现之前先把技能设计架构给读者展示,如下图所示:
关于技能的设计,首先要考虑的是这个技能是谁释放的,也就是说的游戏实体类,实体类的设计在此分了三层:IEntity、IPlayer 和 Player,这三个模块同样不继承 Mono,也就是说不挂接到任何对象上,具体的实现会在后面的章节中结合代码详细介绍,技能释放者找到了,接下来设计技能了。
游戏中的技能分好多种:正常释放的技能、被动技能、远程技能等等,这些不同的技能我们也将其进行模块化设计,其实它们的内容是类似的,可以考虑使用脚本自动生成代码。当然对于游戏中众多特效的使用,我们也需要写一个特效管理类,用于创建不同的特效,特效采用的就是模块化管理,特效实现了后,就要考虑特效是根据游戏实体对象的不同动作进行释放的,不同的动作对应着不同的技能,这当然就是不同动作之间的切换,在这里使用了 FSM 有限状态机进行统一调度。
再介绍一个重要的模块——对象池,因为我们的特效会频繁的创建、销毁,还有游戏中的怪物 NPC 也是一样的。当然,其他的游戏管理类在游戏中都比较常见,其他的一些系统比如背包系统、任务系统,这些可以根据消息或者配置文件进行加载读取,这里就不一一说明了。
接下来介绍比较重要的网络游戏服务器,我们的服务器使用的是 Photon Server,用户直接搭建非常方便,在本教程也会把服务器的搭建过程介绍给读者,我们的网络架构采用的是房间模式,同房间的人可以在场景中实时同步,包括技能、动作等等。而该实时同步的实现方式采用的是状态同步,接下来介绍一下 Photon 服务器的体系结构:
为什么选择 Photon Server 作为服务器,因为该服务器提供了负载均衡,以及做大型网络游戏 MMO 等技术实现,用户无需太关心。它的核心使用的是 C++ 编写的,效率无需使用者关心,同时该服务器支持 UDP、TCP、HTTP 和 Web 套接字,它的应用层使用的是 C# 编写的,对于用户编写逻辑非常方便,而且它也支持数据库和非数据库模式,比如 MySQL、SQL Server 等数据库,以及 MongoDB、Redis 等非数据库。
再介绍一下关于服务器的基本工作流程,从客户端角度来看,工作流程也非常简单,非常适合新手学习,客户端连接到主服务器,可以加入大厅,并检索打开游戏列表。当他们在 Master 主服务器上 CreateGame 操作时,游戏实际上并不创建游戏服务器,而是确定人数比较少的游戏服务器,将 IP 地址返回给客户端。当客户端在主服务器上调用 JoinGame 或 JoinRandomGame 操作时,主服务器查找运行游戏的游戏服务器,并将其 IP 返回给客户端。流程图如下所示:
如果客户端与主服务器断开连接,使用刚收到的 IP 连接到游戏服务器,再次调用 CreateGame 或 JoinGame 操作,断线重连都没有任何问题。下面介绍游戏中比较重要的部分,MMO 游戏同步思想。
客户端中的地图,同样也会在服务器中虚拟一个跟客户端大小完全一样的地图,角色就是在这些虚拟空间中同步,角色同步是在一定的区域内进行同步的,也就是在一定的区域内是互相“看见”的,这种看见与客户端的相机裁剪是完全不同的。效果如下图所示:
计算哪些对象在某些区域会频繁移动,这些对象可能会非常耗费 CPU 资源。加速这一计算的一个简单的方法是将虚拟空间划分为固定区域,然后计算哪些区域重叠。客户应该接收这些重叠区域中的项目的所有事件。最简单的算法使用方形的网格,有时我们也称为九宫格算法,如下所示:
物体通过当前的区域推送事件,一旦特定的区域重叠,它自动订阅区域的事件通道,并开始接收包括物品推送的区域事件。为了避免在区域边界频繁地订阅和取消订阅改变,引入了另外的更大的兴趣区域半径:跨越此外半径的订阅区域被取消订阅,客户端停止接收区域事件。用通俗的语言讲就是在服务器虚拟的场景中,会通过不同的玩家生成各自的九宫格区域,其他 NPC 或者玩家在对方的九宫格区域里面,物体都会显示,离开自己的九宫格区域就剪掉,这样也会是考虑到效率问题,因为如果整个场景实时同步计算,这对于客户端和服务器压力都是很大的。九宫格区域如果重合那就把重合的部分都显示出来。如下图所示:
本教程实现的网络游戏架构设计,最终实现的效果图如下所示:
该图是简单的创建房间以及加入房间进行网络同步界面,进入游戏后实现的游戏中的效果如下图所示:
用户创建房间,其他用户加入房间,多人场景在同一房间中同步的效果如下所示:
通过此网络游戏框架可以快速的把网络游戏实现出来,本课程的最后会把服务器和客户端代码都奉献给读者,希望对开发者有所帮助。从下章开始,本教程进行详细介绍架构设计实现。
游戏中的资源量是必须要考虑的问题,游戏品质的好坏都是通过资源表现的,这些资源的管理,作为开发者必须要处理的。对于游戏资源管理,通常的做法是简单的封装几个接口用于资源的加载,如果只是做个 Demo,这样做是没问题的,但是如果做产品,对于资源的需求量是非常大的,而且各个资源的加载也会由于使用不当,出现各种问题,而且游戏讲究的是团队协作,不同的人会有不同的需求,简单的封装几个接口很难满足需求,如果没有一个统一的资源架构管理,代码会出现各种接口版本,最后会出现大量的冗余代码,这样对游戏产品运行效率会产生影响。
另外,还要考虑游戏资源的动态加载更新,主要是为了减少游戏包体的大小,Unity3D 虽然为用户提供了 AssetBundle 资源打包,方便用户将资源打包上传到资源服务器,在游戏启动时会通过本地存放资源的 MD5 文本文件与服务器的保存资源最新的 MD5 码的文本文件作对比,根据其资源对应的 MD5 码不同,将新的资源下载到本地使用,同时将资源文件代替本地的资源文件。我们在封装资源管理类时,也是从产品的角度考虑资源管理问题。
下面开始讲解如何进行资源管理的代码封装,我们对资源管理的封装做了一个比较完善的思考,代码模块如下图所示:
下面来告诉读者为什么这么设计。我们在游戏开发时,对于 Unity 资源,每个资源都是一个 GameObject,只是单独的 GameObject 显然不能满足需求,因为资源既可以是 Scene,也可以是 Prefab,同时也可以是 Asset 文件。这就会涉及到不同的资源类型,如何表示这些资源类型,比如我测试的时候可以使用 prefab,而在正式发布时采用 asset,如果不做分类,在游戏发布时还要修改接口,非常麻烦。但如果设计一个通用的接口,对于资源类型可以使用枚举进行表示,有了这些想法后,开始逐步去实施我们的思想。
首先需要设计一个 ResourceUnit 模块,它是资源的基本单位,也是程序自己封装的资源基本单位,ResourceUnit 类的代码如下所示:
public enum ResourceType{ ASSET, PREFAB, LEVELASSET, LEVEL,}
上面就是我们定义的资源枚举,每一个加载的资源都是一个 ResourceUnit,它可以是 assetbundle,可以是 prefab 实例化,当然也可以是 scene。下面继续完善 ResourceUnit 类,它的实现代码如下所示:
public class ResourceUnit : IDisposable{ private string mPath; private Object mAsset; private ResourceType mResourceType; private List mNextLevelAssets; private AssetBundle mAssetBundle; private int mReferenceCount; internal ResourceUnit(AssetBundle assetBundle, int assetBundleSize, Object asset, string path, ResourceType resourceType) { mPath = path; mAsset = asset; mResourceType = resourceType; mNextLevelAssets = new List(); mAssetBundle = assetBundle; mAssetBundleSize = assetBundleSize; mReferenceCount = 0; } public List NextLevelAssets { get { return mNextLevelAssets; } internal set { foreach (ResourceUnit asset in value) { mNextLevelAssets.Add(asset); } } } public int ReferenceCount { get { return mReferenceCount; } } //增加引用计数 public void addReferenceCount() { ++mReferenceCount; foreach (ResourceUnit asset in mNextLevelAssets) { asset.addReferenceCount(); } } //减少引用计数 public void reduceReferenceCount() { --mReferenceCount; foreach (ResourceUnit asset in mNextLevelAssets) { asset.reduceReferenceCount(); } if (isCanDestory()) { Dispose(); } } public bool isCanDestory() { return (0 == mReferenceCount); } public void Dispose() { ResourceCommon.Log("Destory " + mPath); if (null != mAssetBundle) { //mAssetBundle.Unload(true); mAssetBundle = null; } mNextLevelAssets.Clear(); mAsset = null; }}
ResourceUnit 类同时实现了资源的引用计数,该设计思想跟内存的使用比较类似,这样便于程序知道对于加载的资源什么时候销毁,什么时候可以继续使用,它还声明了一些变量,比如资源的名字等。
另外,程序要加载资源,首先要知道资源加载路径,其次要知道资源类型是 asset bundle 还是 prefab。我们通常会使用一个类专用于资源路径的设置,包括获取资源文件夹、资源路径、获取资源文件以及获取 AssetBundle 包体文件大小等等。该类的代码实现如下所示:
public class ResourceCommon { public static string textFilePath = Application.streamingAssetsPath; public static string assetbundleFilePath = Application.dataPath + "/assetbundles/"; public static string assetbundleFileSuffix = ".bytes"; public static string DEBUGTYPENAME = "Resource"; //根据资源路径获取资源名称 public static string getResourceName(string resPathName) { int index = resPathName.LastIndexOf("/"); if (index == -1) return resPathName; else { return resPathName.Substring(index + 1, resPathName.Length - index - 1); } } //获取文件名字 public static string getFileName(string fileName) { int index = fileName.IndexOf("."); if (-1 == index) throw new Exception("can not find .!!!"); return fileName.Substring(0, index); } //获取文件名字 public static string getFileName(string filePath, bool suffix) { if (!suffix) { string path = filePath.Replace("\\", "/"); int index = path.LastIndexOf("/"); if (-1 == index) throw new Exception("can not find .!!!"); int index2 = path.LastIndexOf("."); if (-1 == index2) throw new Exception("can not find /!!!"); return path.Substring(index + 1, index2 - index - 1); } else { string path = filePath.Replace("\\", "/"); int index = path.LastIndexOf("/"); if (-1 == index) throw new Exception("can not find /!!!"); return path.Substring(index + 1, path.Length - index - 1); } } //获取文件夹 public static string getFolder(string path) { path = path.Replace("\\", "/"); int index = path.LastIndexOf("/"); if (-1 == index) throw new Exception("can not find /!!!"); return path.Substring(index + 1, path.Length - index - 1); } //获取文件后缀 public static string getFileSuffix(string filePath) { int index = filePath.LastIndexOf("."); if (-1 == index) throw new Exception("can not find Suffix!!! the filePath is : " + filePath); return filePath.Substring(index + 1, filePath.Length - index - 1); } //获取文件 public static void getFiles(string path, bool recursion, Dictionary> allFiles, bool useSuffix, string suffix) { if (recursion) { string[] dirs = Directory.GetDirectories(path); foreach (string dir in dirs) { if (getFolder(dir) == ".svn") continue; getFiles(dir, recursion, allFiles, useSuffix, suffix); } } string[] files = Directory.GetFiles(path); foreach (string file in files) { string fileSuffix = getFileSuffix(file); if (fileSuffix == "meta" || fileSuffix == "dll") continue; if (useSuffix && fileSuffix != suffix) continue; string relativePath = file.Replace("\\", "/"); relativePath = relativePath.Replace(Application.dataPath, ""); string fileName = getFileName(file, true); if (allFiles.ContainsKey(fileName)) { allFiles[fileName].Add(relativePath); } else { List list = new List(); list.Add(relativePath); allFiles.Add(fileName, list); } } } //检查文件夹 public static void CheckFolder(string path) { if (!Directory.Exists(path)) Directory.CreateDirectory(path); } //获取文件路径 public static string getPath(string filePath) { string path = filePath.Replace("\\", "/"); int index = path.LastIndexOf("/"); if (-1 == index) throw new Exception("can not find /!!!"); return path.Substring(0, index); } //获取本地路径 public static string getLocalPath(string path) { string localPath = string.Format("{0}/{1}", Application.persistentDataPath, path); if (!File.Exists(localPath)) { if (Application.platform == RuntimePlatform.Android) { localPath = string.Format("{0}/{1}", Application.streamingAssetsPath, path); } else if (Application.platform == RuntimePlatform.WindowsEditor || Application.platform == RuntimePlatform.WindowsPlayer) { localPath = string.Format("file://{0}/{1}", Application.streamingAssetsPath, path); } return localPath; } return "file:///" + localPath; } //获取AssetBundles文件字节 public static byte[] getAssetBundleFileBytes(string path, ref int size) { string localPath; //Andrio跟IOS环境使用沙箱目录 if (Application.platform == RuntimePlatform.Android || Application.platform == RuntimePlatform.IPhonePlayer) { localPath = string.Format("{0}/{1}", Application.persistentDataPath, path + ResourceCommon.assetbundleFileSuffix); } //Window下使用assetbunlde资源目录 else { localPath = ResourceCommon.assetbundleFilePath + path + ResourceCommon.assetbundleFileSuffix; } Debug.Log(localPath); //首先检测沙箱目录中是否有更新资源 if (File.Exists(localPath)) { try { FileStream bundleFile = File.Open(localPath, FileMode.Open, FileAccess.Read); byte[] bytes = new byte[bundleFile.Length]; bundleFile.Read(bytes, 0, (int)bundleFile.Length); size = (int)bundleFile.Length; bundleFile.Close(); return bytes; } catch (Exception e) { Debug.LogError(e.Message); return null; } } //原始包中 else { TextAsset bundleFile = Resources.Load(path) as TextAsset; if (null == bundleFile) Debug.LogError("load : " + path + " bundleFile error!!!"); size = bundleFile.bytes.Length; return bundleFile.bytes; } } }}
以上封装了资源模块通用的一些接口,便于我们在开发中使用。在游戏处理资源过程中,还需要考虑一个问题,程序在请求资源时,要知道资源是在加载过程中,还是已经卸载完成。在程序中会使用一个枚举值进行设置,用于通知程序资源的使用状态,同时会使用委托函数进行具体回调操作,比如资源加载完成,我要知道什么时候加载完成了。根据这些设想,我们用一个类把它实现出来,这个也就是 Request 类,代码实现如下:
//资源请求类型 public enum RequestType { LOAD, UNLOAD, LOADLEVEL, UNLOADLEVEL, } class Request { internal string mFileName; //请求资源相对Assets/完整路径名称 internal ResourceType mResourceType; //委托回调函数 internal ResourcesManager.HandleFinishLoad mHandle; internal ResourcesManager.HandleFinishLoadLevel mHandleLevel; internal ResourcesManager.HandleFinishUnLoadLevel mHandleUnloadLevel; internal RequestType mRequestType; internal ResourceAsyncOperation mResourceAsyncOperation; //构造函数 internal Request(string fileName, ResourceType resourceType, ResourcesManager.HandleFinishLoad handle, RequestType requestType, ResourceAsyncOperation operation) { mFileName = fileName; mResourceType = resourceType; mHandle = handle; mRequestType = requestType; mResourceAsyncOperation = operation; } //构造函数 internal Request(string fileName, ResourceType resourceType, ResourcesManager.HandleFinishLoadLevel handle, RequestType requestType, ResourceAsyncOperation operation) { mFileName = fileName; mResourceType = resourceType; mHandleLevel = handle; mRequestType = requestType; mResourceAsyncOperation = operation; } }
场景与场景之间进行切换过渡时,尤其对于比较大的资源加载,我们通常使用一个进度条进行过渡,为此在框架中封装了一个通用的资源过渡类,代码实现如下:
public class ResourceAsyncOperation { internal RequestType mRequestType; internal int mAllDependencesAssetSize; internal int mLoadDependencesAssetSize; internal bool mComplete; public AsyncOperation asyncOperation; internal ResourceUnit mResource; internal ResourceAsyncOperation(RequestType requestType) { mRequestType = requestType; mAllDependencesAssetSize = 0; mLoadDependencesAssetSize = 0; mComplete = false; asyncOperation = null; mResource = null; } public bool Complete { get { return mComplete; } } //资源加载进度 public int Prograss { get { if (mComplete) return 100; else if (0 == mLoadDependencesAssetSize) return 0; else { //使用assetbundle if (ResourcesManager.Instance.UsedAssetBundle) { if (RequestType.LOADLEVEL == mRequestType) { int depsPrograss = (int)(((float)mLoadDependencesAssetSize / mAllDependencesAssetSize) * 100); int levelPrograss = asyncOperation != null ? (int)((float)asyncOperation.progress * 100.0f) : 0; return (int)(depsPrograss * 0.8) + (int)(levelPrograss * 0.2); } else { return (int)(((float)mLoadDependencesAssetSize / mAllDependencesAssetSize) * 100); } } //不使用 else { if (RequestType.LOADLEVEL == mRequestType) { int levelPrograss = asyncOperation != null ? (int)((float)asyncOperation.progress * 100.0f) : 0; return levelPrograss; } else { return 0; } } } } } }
关于资源的架构思想,我们基本已经完成了,接下来就要考虑如何使用了,但不能直接使用它们,因为它们既不是单例,也不是静态类,它没有提供对外接口,那怎么办呢?这就要想到管理类,对,我们可以使用管理类提供对外的接口,也就是 ResourceManager 类,管理类是对外提供接口的,对于管理类,它通常是单例模式,我们把游戏中的单例分为两种:一种是继承 mono 的单例,一种是不继承 mono 的。我们设计的资源管理类是可以挂接到对象上的,这主要是为了资源更新时使用的。管理类它可以加载资源、销毁资源等等。它的内容实现代码如下:
public class ResourcesManager : UnitySingleton { //是否通过assetbundle加载资源 public bool UsedAssetBundle = false; private bool mInit = false; private int mFrameCount = 0; private Request mCurrentRequest = null; private Queue mAllRequests = new Queue(); //保存读取的Resource信息 //private AssetInfoManager mAssetInfoManager = null; private Dictionary mResources = new Dictionary(); //加载的资源信息 private Dictionary mLoadedResourceUnit = new Dictionary(); public delegate void HandleFinishLoad(ResourceUnit resource); public delegate void HandleFinishLoadLevel(); public delegate void HandleFinishUnLoadLevel(); private void Start() { } public void Init() { mInit = true; } public void Update() { if (!mInit) return; if (null == mCurrentRequest && mAllRequests.Count > 0) handleRequest(); ++mFrameCount; if (mFrameCount == 300) { mFrameCount = 0; } } private void handleRequest() { //使用assetbundle打包功能 if (UsedAssetBundle) { mCurrentRequest = mAllRequests.Dequeue(); //相对Asset的完整资源路径 string fileName = mCurrentRequest.mFileName; switch (mCurrentRequest.mRequestType) { case RequestType.LOAD: { switch (mCurrentRequest.mResourceType) { case ResourceType.ASSET: case ResourceType.PREFAB: { if (mLoadedResourceUnit.ContainsKey(fileName)) { mCurrentRequest.mResourceAsyncOperation.mComplete = true; mCurrentRequest.mResourceAsyncOperation.mResource = mLoadedResourceUnit[fileName] as ResourceUnit; if (null != mCurrentRequest.mHandle) mCurrentRequest.mHandle(mLoadedResourceUnit[fileName] as ResourceUnit); handleResponse(); } else { } } break; case ResourceType.LEVELASSET: { } break; case ResourceType.LEVEL: { // } break; } } break; case RequestType.UNLOAD: { if (!mLoadedResourceUnit.ContainsKey(fileName)) Debug.LogError("can not find " + fileName); else { } handleResponse(); } break; case RequestType.LOADLEVEL: { StartCoroutine(_loadLevel(fileName, mCurrentRequest.mHandleLevel, ResourceType.LEVEL, mCurrentRequest.mResourceAsyncOperation)); } break; case RequestType.UNLOADLEVEL: { if (!mLoadedResourceUnit.ContainsKey(fileName)) Debug.LogError("can not find level " + fileName); else { if (null != mCurrentRequest.mHandleUnloadLevel) mCurrentRequest.mHandleUnloadLevel(); } handleResponse(); } break; } } //不使用打包 else { mCurrentRequest = mAllRequests.Dequeue(); switch (mCurrentRequest.mRequestType) { case RequestType.LOAD: { switch (mCurrentRequest.mResourceType) { case ResourceType.ASSET: case ResourceType.PREFAB: { //暂时不处理,直接使用资源相对路径 } break; case ResourceType.LEVELASSET: { } break; case ResourceType.LEVEL: { } break; } } break; case RequestType.UNLOAD: { handleResponse(); } break; case RequestType.LOADLEVEL: { StartCoroutine(_loadLevel(mCurrentRequest.mFileName, mCurrentRequest.mHandleLevel, ResourceType.LEVEL, mCurrentRequest.mResourceAsyncOperation)); } break; case RequestType.UNLOADLEVEL: { if (null != mCurrentRequest.mHandleUnloadLevel) mCurrentRequest.mHandleUnloadLevel(); handleResponse(); } break; } } } private void handleResponse() { mCurrentRequest = null; } //传入Resources下相对路径名称 例如Resources/Game/Effect1 传入Game/Effect1 public ResourceUnit loadImmediate(string filePathName, ResourceType resourceType, string archiveName = "Resources") { //使用assetbundle打包 if (UsedAssetBundle) { //添加Resource string completePath = "Resources/" + filePathName; //加载本身预制件 ResourceUnit unit = _LoadImmediate(completePath, resourceType); return unit; } //不使用 else { Object asset = Resources.Load(filePathName); ResourceUnit resource = new ResourceUnit(null, 0, asset, null, resourceType); return resource; } } //加载场景 public ResourceAsyncOperation loadLevel(string fileName, HandleFinishLoadLevel handle, string archiveName = "Level") { { ResourceAsyncOperation operation = new ResourceAsyncOperation(RequestType.LOADLEVEL); mAllRequests.Enqueue(new Request(fileName, ResourceType.LEVEL, handle, RequestType.LOADLEVEL, operation)); return operation; } } private IEnumerator _loadLevel(string path, HandleFinishLoadLevel handle, ResourceType resourceType, ResourceAsyncOperation operation) { //使用assetbundle打包 if (UsedAssetBundle) { //加载场景assetbundle int scenAssetBundleSize = 0; byte[] binary = ResourceCommon.getAssetBundleFileBytes(path, ref scenAssetBundleSize); AssetBundle assetBundle = AssetBundle.LoadFromMemory(binary); if (!assetBundle) Debug.LogError("create scene assetbundle " + path + "in _LoadImmediate failed"); //添加场景大小 operation.mLoadDependencesAssetSize += scenAssetBundleSize; AsyncOperation asyncOperation = SceneManager.LoadSceneAsync(ResourceCommon.getFileName(path, false)); operation.asyncOperation = asyncOperation; yield return asyncOperation; handleResponse(); operation.asyncOperation = null; operation.mComplete = true; operation.mResource = null; if (null != handle) handle(); } //不使用 else { ResourceUnit level = new ResourceUnit(null, 0, null, path, resourceType); //获取加载场景名称 string sceneName = ResourceCommon.getFileName(path, true); AsyncOperation asyncOperation = Application.LoadLevelAsync(sceneName); operation.asyncOperation = asyncOperation; yield return asyncOperation; handleResponse(); operation.asyncOperation = null; operation.mComplete = true; if (null != handle) handle(); } } //单个资源加载 ResourceUnit _LoadImmediate(string fileName, ResourceType resourceType) { //没有该资源,加载 if (!mLoadedResourceUnit.ContainsKey(fileName)) { //资源大小 int assetBundleSize = 0; byte[] binary = ResourceCommon.getAssetBundleFileBytes(fileName, ref assetBundleSize); AssetBundle assetBundle = AssetBundle.LoadFromMemory(binary); if (!assetBundle) Debug.LogError("create assetbundle " + fileName + "in _LoadImmediate failed"); Object asset = assetBundle.LoadAsset(fileName); if (!asset) Debug.LogError("load assetbundle " + fileName + "in _LoadImmediate failed"); ResourceUnit ru = new ResourceUnit(assetBundle, assetBundleSize, asset, fileName, resourceType); //添加到资源中 mLoadedResourceUnit.Add(fileName, ru); return ru; } else { return mLoadedResourceUnit[fileName]; } }}
资源管理类进行到这里其实还没有完成,有的读者可能会说,UI 资源的处理,比如要把一个 UI 资源结点动态的挂接到父类的下面,该如何处理?这问题提的非常好,我们在资源管理框架中会专用于 UI 资源的类处理。代码实现如下:
public class LoadUiResource{ public static GameObject LoadRes(Transform parent,string path) { if(CheckResInDic(path)) { if(GetResInDic(path) != null){ return GetResInDic(path); } else{ LoadResDic.Remove(path); } } GameObject objLoad = null; ResourceUnit objUnit = ResourcesManager.Instance.loadImmediate(path, ResourceType.PREFAB); if (objUnit == null || objUnit.Asset == null) { Debug.LogError("load unit failed" + path); return null; } objLoad = GameObject.Instantiate(objUnit.Asset) as GameObject; objLoad.transform.parent = parent; objLoad.transform.localScale = Vector3.one; objLoad.transform.localPosition = Vector3.zero; LoadResDic.Add(path,objLoad); return objLoad; } //创建窗口子对象,不加入资源管理 public static GameObject AddChildObject(Transform parent, string path) { GameObject objLoad = null; ResourceUnit objUnit = ResourcesManager.Instance.loadImmediate(path, ResourceType.PREFAB); if (objUnit == null || objUnit.Asset == null) { Debug.LogError("load unit failed" + path); return null; } objLoad = GameObject.Instantiate(objUnit.Asset) as GameObject; objLoad.transform.parent = parent; objLoad.transform.localScale = Vector3.one; objLoad.transform.localPosition = Vector3.zero; return objLoad; } //删除所有的孩子 public static void ClearAllChild(Transform transform) { while (transform.childCount > 0) { GameObject.DestroyImmediate(transform.GetChild(0).gameObject); } transform.DetachChildren(); } public static void ClearOneChild(Transform transform,string name) { for (int i = 0; i < transform.childCount; i++) { if (transform.GetChild(i).gameObject.name == name) { GameObject.DestroyImmediate(transform.GetChild(i).gameObject); } } } //删除加载 public static void DestroyLoad(string path) { if(LoadResDic == null || LoadResDic.Count == 0) return; GameObject obj = null; if (LoadResDic.TryGetValue(path, out obj) && obj != null) { GameObject.DestroyImmediate(obj); LoadResDic.Remove(path); //System.GC.Collect(); } } public static void DestroyLoad(GameObject obj) { if(LoadResDic == null || LoadResDic.Count == 0) return; if(obj == null) return; foreach(string key in LoadResDic.Keys) { GameObject objLoad; if(LoadResDic.TryGetValue(key,out objLoad) && objLoad == obj) { GameObject.DestroyImmediate(obj); LoadResDic.Remove(key); break; } } } //获取在目录中的资源 public static GameObject GetResInDic(string path) { if(LoadResDic == null || LoadResDic.Count == 0) return null; GameObject obj = null ; if(LoadResDic.TryGetValue(path,out obj)) { return obj; } return null; } //检查资源是否存在 public static bool CheckResInDic(string path) { if(LoadResDic == null || LoadResDic.Count == 0) return false; return LoadResDic.ContainsKey(path); } public static void Clean() { if(LoadResDic == null || LoadResDic.Count == 0) return; for(int i = LoadResDic.Count - 1;i >=0;i--) { GameObject obj = LoadResDic.ElementAt(i).Value ; if( obj != null) { GameObject.DestroyImmediate(obj); } } LoadResDic.Clear(); } public static Dictionary LoadResDic = new Dictionary();}
该类主要作用是提供了加载 UI 资源的接口,同时会将资源放到字典中便于统一处理。
这样整个资源管理的设计就完成了,在使用时需要把 ResourceManager 类挂接到对象上,目的是为了同资源更新模块结合起来。
为什么要使用消息分发函数?在 Unity 代码设计中,这个问题是不可回避的,因为在开发产品时,不可避免的是各个模块之间会有或多或少的联系,但是为了模块的扩展性,各个代码模块之间的耦合性必须降低,否则产品上线后,版本迭代会出现各种问题。有人可能会说,可以使用单例模式、静态类等等,在此就给读者普及一下知识点。
先说一下单例模式,如果逻辑相对来说比较简单,它是可以的,但是如果逻辑比较复杂,那单例的调用会非常频繁,从而导致逻辑混乱,这是不可取的。静态类是常驻内存的,在游戏开发中除了一些指定的加载数据常驻内存,一般不会使用过多的静态类,所以也是不可取的。而且单例和静态二者也不会降低模块之间的耦合性,最终我们只能考虑消息分发函数,下面先介绍 Unity 引擎自带的消息分发函数。
Unity 引擎也为开发者提供了消息分发函数:SendMessage、SendMessageUpwards、BroadcastMessage,它们也可以实现简单的消息发送,函数内部的参数在这里就不一一介绍了。现在说一下为什么不选择它,因为它们的执行效率相对委托来说是比较低的,网上有关于测试效率的案例,而且扩展性方面也不好,比如我会使用很多的参数进行传递,它很难满足我们的需求,游戏开发还会有更多的类似需求。所以我们放弃它们,选择使用委托自己去封装。
自己定义消息分发,选择的也是委托的方式,首先我们要清楚封装事件是用于做啥事情的?先举一个需求说明。
当玩家杀怪获取到掉落下来的道具时,玩家的经验值加1。这是一个很基础的功能需求,这类需求充斥着游戏的所有地方。当然我们可以不使用事件系统,直接在 OnTriggerEnter 方法中给该玩家的生命值加1就好了,但是,这将使得检测碰撞的这块代码直接引用了玩家属性管理的代码,也就是代码的紧耦合。而且,在后来的某一天,我们又想让接到道具的同时还在界面上显示一个图标,这时又需要在这里引用界面相关的代码。后来,又希望能播放一段音效……,这样随着需求的增加,逻辑会越来越复杂。解决此问题的好办法就是,在 OnTrigerEnter 中加入消息分发函数,这样具体的操作就在另一个类的函数中进行,耦合性降低。
另外,在网络游戏中,我们也会遇到服务器发送给客户端角色信息后,客户端接收到该消息后,接下来做会将得到的角色信息在 UI 上显示出来。如果不用事件系统对其进行分离,那么网络消息跟 UI 就混在一起了。这样随着逻辑的需求增加,耦合性会越来越大,最后会导致项目很难维护。
既然事件系统这么重要,我们必须要使用它解耦合模块,下面说说设计思路。
游戏中会有很多事件,事件的分类表示我们可以采用字符串或者采用枚举值,事件系统使用的是枚举值,事件分类枚举代码表示如下所示:
public enum EGameEvent{ eWeaponDataChange = 1, ePlayerShoot = 2, //UI eLevelChange = 3, eBloodChange = 4, ePowerChange = 5, eSkillInit = 6, eSkillUpdate = 7, eBuffPick = 8, eTalent = 9, eBlood = 10, eMp = 11, eScore = 12, ePower = 13, eTalentUpdate = 14, ePickBuff = 15, eGameEvent_LockTarget, //Login eGameEvent_LoginSuccess, //登陆成功 eGameEvent_LoginEnter, //登录界面 eGameEvent_LoginExit, eGameEvent_RoleEnter, eGameEvent_RoleExit, //Play eGameEvent_PlayEnter, eGameEvent_PlayExit, ePlayerInput, eActorDead,}
这些事件分类还可以继续扩展,事件系统贯穿于整个游戏,从 UI 界面、登录、战斗等等。我们的事件系统实现主要分为三步:事件监听、事件分发、事件移除。还有一个问题,事件和委托是保存在哪里的?我们使用了字典 Dictionary 用于保存事件和委托。代码如下:
static public Dictionary mEventTable = new Dictionary();
事件系统中的委托,也需要我们自己封装,可以思考一下,委托该如何封装?我们使用的委托函数的参数可能会有多个,而且不同的委托函数对应的类型可能也是不同的,比如 GameObject、float、int 等等。针对这些需求,唯一能帮我们解决问题的就是模版类,回调函数对应的代码如下:
public delegate void Callback();public delegate void Callback(T arg1);public delegate void Callback(T arg1, U arg2);public delegate void Callback(T arg1, U arg2, V arg3);public delegate void Callback(T arg1, U arg2, V arg3, X arg4);
最多列举了四个参数的回调函数,下面开始事件类的封装了。先封装监听函数:
//无参数 static public void AddListener(EGameEvent eventType, Callback handler) { OnListenerAdding(eventType, handler); mEventTable[eventType] = (Callback)mEventTable[eventType] + handler; } //一个参数 static public void AddListener(EGameEvent eventType, Callback handler) { OnListenerAdding(eventType, handler); mEventTable[eventType] = (Callback)mEventTable[eventType] + handler; } //两个参数 static public void AddListener(EGameEvent eventType, Callback handler) { OnListenerAdding(eventType, handler); mEventTable[eventType] = (Callback)mEventTable[eventType] + handler; } //三个参数 static public void AddListener(EGameEvent eventType, Callback handler) { OnListenerAdding(eventType, handler); mEventTable[eventType] = (Callback)mEventTable[eventType] + handler; } //四个参数 static public void AddListener(EGameEvent eventType, Callback handler) { OnListenerAdding(eventType, handler); mEventTable[eventType] = (Callback)mEventTable[eventType] + handler; }
每个函数都比较简单,从没有参数,到最多四个参数的函数一一给读者展示出来。这些函数都调用了函数 OnListenerAdding 用于将事件和委托粗放到字典中,监听函数有了,对应的就是移除监听函数,移除就是从 Dictionary 字典中将其移除掉,它跟监听函数是一一对应的函数如下:
//No parameters static public void RemoveListener(EGameEvent eventType, Callback handler) { OnListenerRemoving(eventType, handler); mEventTable[eventType] = (Callback)mEventTable[eventType] - handler; OnListenerRemoved(eventType); } //Single parameter static public void RemoveListener(EGameEvent eventType, Callback handler) { OnListenerRemoving(eventType, handler); mEventTable[eventType] = (Callback)mEventTable[eventType] - handler; OnListenerRemoved(eventType); } //Two parameters static public void RemoveListener(EGameEvent eventType, Callback handler) { OnListenerRemoving(eventType, handler); mEventTable[eventType] = (Callback)mEventTable[eventType] - handler; OnListenerRemoved(eventType); } //Three parameters static public void RemoveListener(EGameEvent eventType, Callback handler) { OnListenerRemoving(eventType, handler); mEventTable[eventType] = (Callback)mEventTable[eventType] - handler; OnListenerRemoved(eventType); } //Four parameters static public void RemoveListener(EGameEvent eventType, Callback handler) { OnListenerRemoving(eventType, handler); mEventTable[eventType] = (Callback)mEventTable[eventType] - handler; OnListenerRemoved(eventType); }
监听函数和移除监听函数都封装完了,那么如何触发监听函数这就是我们通常所说的广播函数,它与监听和移除也是一一对应的,代码片段如下所示:
//No parameters static public void Broadcast(EGameEvent eventType) { OnBroadcasting(eventType); Delegate d; if (mEventTable.TryGetValue(eventType, out d)) { Callback callback = d as Callback; if (callback != null) { callback(); } else { throw CreateBroadcastSignatureException(eventType); } } } //Single parameter static public void Broadcast(EGameEvent eventType, T arg1) { OnBroadcasting(eventType); Delegate d; if (mEventTable.TryGetValue(eventType, out d)) { Callback callback = d as Callback; if (callback != null) { callback(arg1); } else { throw CreateBroadcastSignatureException(eventType); } } } //Two parameters static public void Broadcast(EGameEvent eventType, T arg1, U arg2) { OnBroadcasting(eventType); Delegate d; if (mEventTable.TryGetValue(eventType, out d)) { Callback callback = d as Callback; if (callback != null) { callback(arg1, arg2); } else { throw CreateBroadcastSignatureException(eventType); } } } //Three parameters static public void Broadcast(EGameEvent eventType, T arg1, U arg2, V arg3) { OnBroadcasting(eventType); Delegate d; if (mEventTable.TryGetValue(eventType, out d)) { Callback callback = d as Callback; if (callback != null) { callback(arg1, arg2, arg3); } else { throw CreateBroadcastSignatureException(eventType); } } } //Four parameters static public void Broadcast(EGameEvent eventType, T arg1, U arg2, V arg3, X arg4) { OnBroadcasting(eventType); Delegate d; if (mEventTable.TryGetValue(eventType, out d)) { Callback callback = d as Callback; if (callback != null) { callback(arg1, arg2, arg3, arg4); } else { throw CreateBroadcastSignatureException(eventType); } } } }
另外把 OnListenerAdding 函数封装如下,它主要是将事件和委托存放到字典中,如下所示:
static public void OnListenerAdding(EGameEvent eventType, Delegate listenerBeingAdded) { if (!mEventTable.ContainsKey(eventType)) { mEventTable.Add(eventType, null ); } Delegate d = mEventTable[eventType]; if (d != null && d.GetType() != listenerBeingAdded.GetType()) { throw new ListenerException(string.Format("Attempting to add listener with inconsistent signature for event type {0}. Current listeners have type {1} and listener being added has type {2}", eventType, d.GetType().Name, listenerBeingAdded.GetType().Name)); }}
这样我们的整个事件系统就封装完成了,最后告诉读者如何使用?首先需要先监听,将监听函数放在对应的类中,代码如下所示:
EventCenter.AddListener(EGameEvent.eGameEvent_GamePlayEnter, Show);
然后在另一个类文件中,可以播放此消息。代码如下所示:
EventCenter.Broadcast(EGameEvent.eGameEvent_GamePlayEnter);
阅读全文: http://gitbook.cn/gitchat/column/5a3921aec5896e6e1cf1a129