

[Unity独立游戏开发-程序框架1.0]汇总版
CoParks
游戏爱好者/在读研究生
阅读 1472
2022年8月3日
设计实现独立游戏程序框架。涵盖对象池、事件中心、UI框架、音效系统、场景异步加载、多用户存档、配置系统,并结合Odin插件。课程视频 https://learn.u3d.cn/tutorial/indie-framework-joker 作者Joker
1.程序框架需求分析与设计
1.1 框架需求分析
1.1.1 框架主体
框架主体应基于单例模式和发布-订阅者模式,实现各功能模块的完全解耦,应具备功能系统、功能服务、工具组件 、插件扩展、配置管理等部分,能够将重用模块进行有机组合,并对顶层开放清晰可用的开发接口,在开发新项目时可以一键导入使用。由于框架这一部分代码会被其他人频繁调用,需要为开发者提供简明扼要的使用说明,将每个系统的功能,主要类,方法及方法的参数,返回值,功能,相关预制体进行整理并提供使用示例。
1.1.2 事件系统
框架的事件系统主要负责高效的方法调用与数据传递,实现各功能之间的解耦,通常在调用某个实例的方法时,必须先获得这个实例的引用或者新实例化一个对象,低耦合度的框架结构希望程序本身不去关注被调用的方法所依托的实例对象是否存在,通过事件系统做中转将功能的调用封装成事件,使用事件监听注册、移除和事件触发完成模块间的功能调用管理。事件名最好以String类型进行唯一Key标识,考虑到C#中枚举类型没有实现IEquatable接口,使用Dictionary进行事件封装若采用枚举类型作为将会发生装箱,使得效率降低。
1.1.3 UI框架
UI作为一款游戏与玩家交互时间最长的功能部分,其性能的优劣很大程度影响了用户的核心体验,UI框架应该实现对窗口的生命周期管理,层级遮罩管理,按键物理响应等功能,对外提供窗口的打开、关闭逻辑,对内优化好窗口的缓存、层级问题,能够和场景加载、事件系统联动,将Model、View、Controller完全解耦。通过与配置系统、脚本可视化合作,实现新UI窗口对象的快速开发和已有UI窗口的方便接入。
1.2 总体设计
开发一个低耦合度、可二次开发的游戏程序框架,综合使用单例模式、发布-订阅者模式、MVC架构,通过单例模式变种支持的多功能初始化,利用框架级GameRoot 的Awake方法、管理器级Manager的Init方法,游戏场景级LVManager的Start方法来对不同层级对象进行有序初始化,利用代理的思想托管生命周期函数和协程,为普通类脚本提供更多支持。实现对象池系统、事件系统、存档系统、场景系统、配置系统、资源服务、音效服务、UI框架、Mono托管服务等功能。其中框架整体基于单例模式和对象池,单例模式应具体包含四种单例,支持继承Mono的单例,非继承Mono的单例,包含Init方法用于管理器初始化的单例,包含OnEnable、OnDisable方法用于事件监听注册、移除的单例。对象池有两种,GameObject对象池和非GameObject对象池,能够对所有Object进管理。结合属性和自定义方法对程序进行精简化、泛用化。除脚本外,框架还包括预制的UI 动画、预制体、资源文件、扩展插件、配置对象等,总体设计图和主要功能设计图如图1.2和1.3所示。

图1.2 程序框架总体设计图

a) 主要功能设计图part1

b) 主要功能设计图part2
图1.3 程序框架总体设计图
1.3 详细设计
1.3.1 对象池
在Unity中,对象的生成、销毁都需要性能开销,在一些特定的应用场景下需要进行大量重复物体的克隆,因此需要通过设计对象池来实现重复对象的使用完暂存,待需要时直接拿来用,而不是重新生成一个对象。本节设计的对象池有两种:GameObject对象池和非GameObject对象池,UML类图如图1.4所示。
首先对GameObject和非GameObject进行说明,Unity中所有挂载到GameObject(Hierarchy窗口内的对象)上的脚本都要继承自MonoBehavior类来启用Unity特有的生命周期、协程等功能,且GameObject对象存在父子关系,在GameObject对象池中,除了需要对GameObject及其组件进行收容管理,还要对应调整其在Hierarchy的层级关系,GameObject是一个实实在在的游戏对象,而非GameObject对象池只需对Object进行管理,其本质是一个类对象,也就是一个脚本对象,如状态机中的状态类。
PoolAttribute类负责对需要对象池管理的对象进行Pool属性标记。
PoolManager类负责对以上两种对象池进行管理, 两个字典gameObjectPoolDic和objectPoolDic对对象池索引进行存储,poolRootObj作为所有对象池的父节点,其主要包含三部分功能:对GameObject对象相关操作,普通对象相关操作,对象删除。
在GameObject对象相关操作中,设计GetGameObject方法的若干重载方法,实现通过预制体(本质是GameObject对象)或资源路径对应的对象在所有对象池中取出对应池子中的对象,也可传入泛型参数T来获取游戏对象上的某个组件。设计PushGameObject方法来将GameObject放回对象池。设计CheckGameObjectCache方法在对象放入取出过程中进行键值校对,确保对象池中有对应的一层数据,设计CheckCacheAndLoadGameObject方法对二级目录进行分割来快速获取预制体的名字,再在字典中进行查找,适用于UI资源。
在普通对象相关操作中,类似的,设计GetObject<T>、PushObject、CheckObjectCache方法实现对普通脚本对象进行收纳、检查。
GameObjectPoolData类负责对gameObjectPoolDic字典中具体存储的一个GameObject对象池数据进行管理,包含对象池父节点fatherObj和对象队列容器PoolQueue 。设计PushObj、GetObj方法和构造方法GameObjectPoolData实现对GameObject对象池的初始化、对象放入、对象取出,需要注意的是,PoolManager中的push和get相关操作是面向外界操作逻辑的,负责从所有对象池中找到特定的那一个对象池,具体的数据存储交由GameObjectPoolData类中的push和get方法完成。
ObjectPoolData负责对objectPoolDic字典中具体存储的一个Object对象池数据进行管理,类似的,其也有相应的push、get方法和构造函数。

图1.4 对象池UML类图
总之,整个对象池实现了对Hierarchy窗口上实际可见的GameObject和只存在于脚本内的普通类对象的管理,通过缓存极大地减少了对象的生成和销毁,提升了游戏整体性能。相比普通类对象池,GameObject类对象池需要额外处理Hierarchy中的父子关系和对象的激活与否。
1.3.2 存档系统
完成对存档的创建,获取,保存,加载,删除,缓存,支持多存档。存档有两类,一类是用户型存档,存储着某个游戏用户具体的信息,如血量,武器,游戏进度,一类是设置型存档,与任何用户存档都无关,是通用的存储信息,比如屏幕分辨率、音量设置等,UML类图如图1.5所示。
SaveItem是存档类,包含某个用户存档的信息,主要由ID、最后更新时间等存档属性,需要注意的是,存档内容并不会保存在SaveItem中,而是保存在本地文件中,其只是信息的载体。
SaveManager类是实际的存档读写以及存档获取的入口,其内含了SaveManagerData类,用于保存用户存档管理器的数据,包含当前存档ID和所有存档的列表、持有存档保存的路径以及缓存字典,避免频繁从本地加载一个对象,消除性能隐患。SaveManager构造方法包含了存档路径的生成以及存档目录的创建,存档路径的根目录由计算机系统的持久化路径Application.persistentDataPath决定,同时调用InitSaveManagerData方法初始化用户存档管理器。SaveManager类中包含六部分功能,负责存档的设置、存档、缓存、对象、全局数据、工具方法。
在存档设置的部分,除上文提到的InitSaveManagerData方法获取存档管理器数据,还包括UpdateSaveManager方法更新存档管理其数据,获取所有存档的GetAllSavItem方法,按照更新时间排序获取所有存档的GetAllSaveItemByUpdateTime方法及相应地提供排序依据实现IComparer类并重写Compare方法的OrderByUpdateTimComparer类,以及集成如上所有功能对外提供自定义比较依据Function的万能GetAllSaveItem泛型方法。
在存档部分,主要有获取、添加、删除(传对象或者ID)对应的GetSaveITem,CreateSaveItem,DeleteSaveItem方法。
在缓存部分,主要有设置、获取、移除缓存对应的SetCache、GetCache、RemoveCache方法
在数据对象操作部分,主要有保存对象至某个存档的SaveObject方法(重载支持使用ID或SaveItem对象,使用对象类型名或指定文件名进行存储),从某个具体的存档中加载某个对象的LoadObject方法(同样重载支持使用ID或SaveItem对象,使用对象类型名或指定文件名进行加载)。
在全局数据部分,主要有加载、存储全局设置的LoadSetting、SaveSetting方法(重载支持使用文件名或者对象类型名)。
在工具函数部分,主要有将底层数据转化成二进制文件流写入、读出文件的SaveFile、LoadFile方法以及获取某个存档的路径的GetSavePath方法供上层调用。
需要注意的是,实际用户存档SaveItem和设置存档SaveSetting存数据对于工具函数底层写入文件时不做区分,其原因在于存的时候只需要传一个object转成二进制流放到指定位置,取的时候传泛型T和其他参数即可锁定位置且知道要把object强转成什么类型的存档数据。存档支持多存档,且每个存档下面可以存多个对象,无论是游戏设置类还是玩家类存档,只不过日常封一个对象存就足够,存几个对象可以自定,游戏设置类存档的全局唯一实际是指设置存档的文件夹只有一个,而玩家类存档根据生成的唯一ID可以有多个存档,SaveManagerData类存储了当前的玩家存档ID和所有存档的List,与全局设置存档完全无关,设置类型存档全局唯一。

图1.5 存档系统UML类图
1.3.3 场景系统
场景系统负责Scene切换过程中进行管理,并在切换完成后执行回调函数,分两种加载方式:
同步加载:在运行时到加载这一行的时候,程序会卡住等待资源加载完毕。如果资源本身加载比较耗时,这个卡顿的时间较久则会影响玩家体验,在切换场景时小场景可以直接使用同步加载,卡顿感不会很明显。
异步加载:不影响游戏主逻辑的运行,资源一般在加载完毕后用触发委托的方式使用,游戏中常见的Loading窗口即使用异步加载,在前台播放加载动画时,会异步加载场景资源,完成后关闭Loading窗口即可。
SceneManager是场景系统的核心类,UML类图如图1.6所示,其中包含记录当前异步加载进度的currentProgress和targetProgress变量,同步加载场景LoadScene方法,异步加载场景LoadSceneAsync方法以及对应的协同启动方法DoLoadSceneAsync方法。

图1.6 场景系统UML类图
1.3.4 配置系统
配置系统基于Scriptable Object和Odin插件,使得游戏数据的配置文件可视化呈现在Inspector面板上,便于对游戏数据进行设置,UML类图如图1.7所示。
ConfigManager类是配置系统的核心管理类,持有ConfigSetting的对象,使用GetConfig方法获取下层configSetting对象中的配置。
ConfigBase是配置基类,游戏内的角色配置、武器配置等都继承自此类,其继承自序列化的ScriptableObject类,支持字典序列化。ConfigSetting类容纳了所有游戏配置,其包含有字典作为值类型的configDic,使用string类型作为不同类型配置文件的索引,内含字典列表容纳同一类型的所有配置文件,以int作为列表索引,并通过GetConfig方法获取某类配置中的一个具体的配置。
GameSetting类是框架层面的游戏配置,继承ConfigBase类,结合Unity编译前运行在游戏运行前扫描自定义的一些属性,其存在的意义在于完善其他功能系统的初始化过程并将状态可视化呈现在面板上,包括对象池的缓存设置、UI的设置。相关方法有InitForEditor,调用PoolAttributeOnEditor方法和UIElementAttributeOnEditor方法,通过在GameRoot中的编译前部分进行调用执行PoolAttributeOnEditor方法负责将带有Pool特性的类型加入缓存池字典,UIElementAttributeOnEditor方法负责遍历程序集下的每个类型找出带有UI属性的类并按照属性内的配置进行UI资源的加载和分层。

图1.7 配置系统UML类图
1.3.5 资源服务
通常在Unity中使用Resources.Load和Instantitate来获取资源和实例化物体对象,资源服务封装了这些方法统一管理所有的Unity资源、C#脚本对象、GameObject游戏对象,并使得游戏脚本和脚本挂载到的游戏对象自动收到对象池管理,支持异步加载和方法回调,UML类图如图1.8所示。
ResManager作为资源服务核心类,有wantCacheDic字典存储所有需要对象池缓存的类型,在初始化时通过配置系统获得。有加载Unity资源类型的LoadAsset方法,加载游戏对象的LoadGameObject的方法,加载普通类对象的Load方法,异步加载游戏对象的LoadGameObjectAsync方法及相应的协程方法,异步加载Unity资源的LoadAssetAsync方法及相应的协程方法,使用Resources.Load和Instantiate获取预制体的GetPrefab和InstantiateForPrefab方法。

图1.8 资源服务UML类图
1.3.6 音效服务
音效服务集成了背景、特效音乐播放,音量、播放控制功能。AudioManager作为音效服务的核心类,包含了全局音量globalVolume、背景音量bgVolume、特效音量effectVolume、静音布尔量isMute、暂停布尔量isPause等音量相关的属性,UpdateAllAudioPlay、UpdateBGAudioPlay、SetEffectAudioPlay等更新和设置音量的相关方法,播放背景音乐的PlayBGAudio方法且重载后支持播放特定的音乐资源,播放特效音乐PlayOnShot方法且重载后支持在指定位置或指定游戏对象上播放特定的音乐,特效音乐由于要重复使用,结合对象池和协程设计了GetAudioPlay、RecycleAudioPlay、DoRecycleAudioPlay方法,可以从对象池中获取播放器并自动回收,支持播放后执行回调事件,UML类图如图1.9所示。

图1.9音效服务UML类图
1.3.7 托管Mono服务
在Unity中,有些时候需要普通C#类去调用协程和Unity生命周期函数,所有游戏对象上挂载的脚本都继承自MonoBehavior类,自动获取生命周期函数、协程、获取组件、搜索游戏对象等功能,但前提是必须依附于实际存在于Hierarchy窗口中的GameObject上,对于像状态机这种不必挂载到物体上的脚本,想要调用协程等功能必须经过一层中转来辅助调用,即可以让所有不需要挂载到面板上的脚本都借助面板上的特定中心调用成Mono相关服务,只需要将参数传递给托管中心即可,通过这种方式,可以将GameRoot上挂载的所有系统Manager类取消挂载,只保留托管中心。
MonoManager作为托管服务的核心类,包含Update、LateUpdate、FixedUpdate生命周期函数的Event容器以及对应的事件添加、移除方法,最后统一通过MonoManager的生命周期函数进行中转调用,UML类图如图1.10所示。

图1.10托管服务UML类图
1.3.8 拓展方法和自定义特性服务
在框架中有一些功能的重用性很高且针对对象本身进行操作,如将对象放入/取出对象池,获取对象身上的自定义属性,托管服务等,尽管已经使用单例模式进行简化,但每次调用方法时仍需要先通过xxx.Instance的方式先获取功能的单例。Unity中拓展方法因其第一个参数默认是object本身(this object的形式),使得其可以通过对象.的方式直接访问,故通过拓展方法,将通用功能封装成为对象本身的方法直接调用。在Unity中特性一般是给函数、字段、类、枚举加标签以提高开发效率,在框架中,对UI和受对象池管理的类添加自定义特性,结合配置系统实现类型的自动识别。Extension类是拓展方法和自定义特性服务的核心类,其中包含了特性的获取、资源管理、Mono相关的拓展方法,UML类图如图1.11所示。

图1.11拓展方法和自定义特性服务UML类图
1.3.9 事件系统
事件系统由四部分组成:内部类和接口、事件监听的添加、事件监听的移除、事件的触发。事件系统的核心类EventManager持有事件信息的接口IEventInfo,事件的内容使用EventInfo类实现IeventInfo接口中的Init和Destroy方法对信息进行管理,利用多元泛型类实现对多参数方法的支持,最多支持三个参数的事件信息,EventInfo类由于要重复利用,其生成和回收同样受到对象池管理。持有所有事件的字典容器eventInfoDic。AddEventListener方法和RemoveEventListener方法负责对事件字典进行添加和移除,EventTrigger方法负责对事件进行触发,UML类图如图1.12所示。

图1.12事件系统UML类图
1.3.10 UI框架设计
UI框架负责窗口的显示/加载、关闭/缓存,窗口的层级管理,异步加载过程中的Loading界面联动,UML类图如图1.13所示。
核心类UIManager作为外部调用的顶层逻辑,持有所有UI元素的字典容器UIElementDic,其内部类UILayer负责管理窗口层级。有打开、关闭UI窗口的Show、Close方法以及关闭所有窗口的CLoseAll方法,其内部要做好面板的缓存、层级问题。
UIElementAttribute类定义了UI窗口的特性,包括资源路径、是否需要缓存、默认层级,通过和GameSetting、自定义特性服务,实现UI窗口对象在Unity运行前的自动加载。
UIElement类是对UI窗口及其特性的二次封装,作为UIManager内的字典实际存储的内容。
UIWindow_Base类是所有UI窗口的基类,开发过程中创建的UI对象挂载的脚本均继承自此类,其中定义了窗口对象的常见操作方法,包括窗口结果的枚举类型、窗口类型、初始化方法Init、窗口打开时执行的逻辑方法OnShow、OnClose、特定事件的监听注册、取消方法RegisterEventListener和CancelEventListener。
UI_LoadingWindow 是对UI框架的实际应用,实际编写了异步加载进过程中度条的UI窗口,主要包含了View层的UI元素和对应的事件注册、移除监听方法以及加载完成的功能逻辑。

图1.13 UI框架UML类图
2.程序框架功能实现
2.1框架核心实现
框架整体依托于游戏对象GameRoot在场景中起到作用,也就是游戏根节点,其结构图如图2.1所示,GameRoot本身是跨场景不销毁的,所以其需要用DontDestroyOnload修饰,GameRoot脚本的Awake方法是游戏运行时最先执行的生命周期函数(通过设计其他脚本中不存在Awake方法存在 ),GameRoot全局唯一,若已有本身实例,则将新生成的自己销毁避免冲突,在完成后调用所有管理器类的Init方法完成相应功能的初始化。
protected override void Awake() { if (Instance != null) { Destroy(gameObject); //自己多余 return; } base.Awake(); DontDestroyOnLoad(gameObject); InitManagers(); } private void InitManagers() { ManagerBase[] managers = GetComponents<ManagerBase>(); for (int i = 0; i < managers.Length; i++) { managers[i].Init(); } }
GameRoot游戏对象(与GameRoot脚本区分)存在于Hierarchy场景中,且在运行时在DontDestroyOnload下,其子物体包含了游戏的核心系统,包括对象池根节点PoolRoot、背景、特效音乐播放器以及UI根节点Root。挂载了所有继承Mono的管理器脚本类。

图2.1 游戏根节点GameRoot结构图
GameRoot类中有一部分Unity编译时的逻辑,便于在游戏运行前就完成游戏配置的生成和UI部分功能的初始化,使用#ifUNITY_EDITOR来限定此部分逻辑只会在Editor下运行,并不会被build进最终的包体中。
#if UNITY_EDITOR [InitializeOnLoadMethod] public static void InitForEditor() { // 当前是否要进行播放或准备播放中 if (EditorApplication.isPlayingOrWillChangePlaymode) { return; } if (Instance == null && GameObject.Find("GameRoot") != null) { Instance = GameObject.Find("GameRoot").GetComponent<GameRoot>(); // 清空事件 EventManager.Clear(); Instance.InitManagers(); Instance.GameSetting.InitForEditor(); UI_WindowBase[] window = In stance.transform.GetComponentsInChil dren<UI_WindowBase>(); foreach (UI_WindowBase win in window) { win.OnShow(); } } } #endif
2.2 通用功能实现
2.2.1 对象池
PoolManager继承自ManagerBase管理器基类,挂载在GameRoot下,在游戏运行时通过单例实例化,通过GetGameObject方法获取对象池中对象,传入参数后经转换得到对象池String类型Key值,在GameObject对象池字典中查找是否存在对应的对象池,存在调用下一层GetObj方法返回GameObject,如果不存在说明对象池中没有这一个对象池,实例化一个对象返回。
public T GetGameObject<T>(GameObject prefab, Transform parent = null) where T : UnityEngine.Object//重载方法1 public GameObject GetGameObject(string path, Transform parent = null)//重载方 法2 public GameObject GetGameObject(GameObject prefab, Transform parent = null) { GameObject obj = null; string name = prefab.name; // 检查有没有这一层 if (CheckGameObjectCache(prefab)) { obj = gameObjectPoolDic[name].GetObj(parent); } // 没有的话给你实例化一个 else { // 确保实例化后的游戏物体和预制体名称一致 obj = GameObject.Instantiate(prefab, parent); obj.name = name; } return obj; }
PushGameObject方法在将对象放回前先检查有没有对应的对象池,没有就向字典中添加一层并通过构造函数生成相应的数据。
public void PushGameObject(GameObject obj) { string name = obj.name; // 现在有没有这一层 if (gameObjectPoolDic.ContainsKey(name)) { gameObjectPoolDic[name].PushObj(obj); } else { gameObjectPoolDic.Add(name, new GameObjectPoolData(obj, pool RootObj)); } }
CheckGameObjectCanche方法负责对象池检查是否存在相应层。
普通对象的GetObject、PushObject、CheckObjectCache方法类似,无需考虑路径,直接通过传入泛型参数找到对应的类对象。
2.2.2 存档系统
存档系统的核心类为SaveManager,包含了SaveManagerData类来存储当前存档ID和所有存档的列表数据。
private class SaveManagerData { // 当前的存档ID public int currID = 0; // 所有存档的列表 public List<SaveItem> saveItemList = new List<SaveItem>(); }
构造函数SaveManager用于在初始化时在指定的存档位置生成对应的目录并初始化SaveManagerData。
static SaveManager() { saveDirPath = Application.persistentDataPath + "/" + saveDirName; settingDirPath = Application.persistentDataPath + "/" + settingDirName; // 确保路径的存在 if (Directory.Exists(saveDirPath) == false) { Directory.CreateDirectory(saveDirPath); } if (Directory.Exists(settingDirPath) == false) { Directory.CreateDirectory(settingDirPath); } // 初始化SaveManagerData InitSaveManagerData(); }
InitSaveManagerData方法内会从存档位置加载存档设置数据,如果没有就会新建一个并更新存档设置数据,及存储到对应的文件中。
private static void InitSaveManagerData() { saveManagerData = LoadFile<SaveManagerData>(saveDirPath + "/SaveManger Data"); if (saveManagerData == null) { saveManagerData = new SaveManagerData(); UpdateSaveManagerData(); } } public static void UpdateSaveManagerData() { SaveFile(saveManagerData, saveDirPath + "/SaveMangerData"); }
GetAllSaveItem相关方法会从SaveItemList中获取所有存档列表,并根据时间或者用户自定义的排序依据进行排序后再输出,以GetAllSaveItemByUpdateTime为例,取出所有SaveItem后,通过重写IComparer接口的Compare方法来自定义排序依据的项,最后返回排序结果。
public static List<SaveItem> GetAllSaveItemByUpdateTime() { List<SaveItem> saveItems = new List<SaveItem>(saveManagerData.saveItemList.Count); for (int i = 0; i < saveManagerData.saveItemList.Count; i++) { saveItems.Add(saveManagerData.saveItemList[i]); } OrderByUpdateTimeComparer orderBy = new OrderByUpdateTimeComparer(); saveItems.Sort(orderBy); return saveItems; } private class OrderByUpdateTimeComparer : IComparer<SaveItem> { public int Compare(SaveItem x, SaveItem y) { if (x.lastSaveTime > y.lastSaveTime) { return -1; } else { return 1; } } }
SetCache、GetCache、RemoveCache方法会通过存档的ID和文件名判断缓存字典中是否有对应的存档ID以及该存档的缓存中是否有这个文件,若有则更新/取出,没有则创建/返回空,以SetCache方法的一种重载为例。
private static void SetCache(int saveID, string fileName, object saveObject) { // 缓存字典中是否有这个SaveID if (cacheDic.ContainsKey(saveID)) { // 这个存档中有没有这个文件 if (cacheDic[saveID].ContainsKey(fileName)) { cacheDic[saveID][fileName] = saveObject; } else { cacheDic[saveID].Add(fileName, saveObject); } } else { cacheDic.Add(saveID, new Dictionary<string, object>() { { fileName, saveO bject } }); } }
在存档存储的底层逻辑中,直接存储的都是object类型对象,并不对存的数据进行区分,统一转化成二进制文件流写入文件并使用泛型T进行数据类型标记,相应的SaveObject方法和LoadObject方法分别进行了四次重载,支持通过存档ID,SaveItem对象存储/取出指定对象,且存储的文件名称可以指定也可以使用默认的对象类型名,存档系统的缓存机制实际是针对加载过程进行优化, LoadObject时候会先看下Cache里面有无记录,没有才会实际调用LoadFile进行文件读取,以SaveObject、LoadObject的一种重载为例。
public static void SaveObject(object saveObject, string saveFileName, SaveItem sa veItem) { // 存档所在的文件夹路径 string dirPath = GetSavePath(saveItem.saveID, true); // 具体的对象要保存的路径 string savePath = dirPath + "/" + saveFileName; // 具体的保存 SaveFile(saveObject, savePath); // 更新存档时间 saveItem.UpdateTime(DateTime.Now); // 更新SaveManagerData 写入磁盘 UpdateSaveManagerData(); // 更新缓存 SetCache(saveItem.saveID, saveFileName, saveObject); } public static T LoadObject<T>(string saveFileName, int saveID = 0) where T : clas s { T obj = GetCache<T>(saveID, saveFileName); if (obj == null) { // 存档所在的文件夹路径 string dirPath = GetSavePath(saveID); if (dirPath == null) return null; // 具体的对象要保存的路径 string savePath = dirPath + "/" + saveFileName; obj = LoadFile<T>(savePath); SetCache(saveID, saveFileName, obj); } return obj; }
全局设置存档的存储和加载与用户存档的加载类似,由于全局唯一没有缓存的必要,直接进行文件操作即可,工具函数中的GetSavePath方法通过两层确认是否有存档对象,再确定是否有对应的存档路径来获得某个存档的路径,底层SaveFile、LoadFile方法以二进制的方式将对象写入/读出文件,以SaveFile为例。
private static void SaveFile(object saveObject, string path) { FileStream f = new FileStream(path, FileMode.OpenOrCreate); // 二进制的方式把对象写进文件 binaryFormatter.Serialize(f, saveObject); f.Dispose(); }
2.2.3 场景系统
在 SceneManager中,LoadScene方法直接调用Unity引擎自带的SceneManager进行场景切换,提供Action参数使得场景加载完毕后可以回调函数。
public static void LoadScene(string sceneName, Action callBack = null) { UnityEngine.SceneManagement.SceneManager.LoadScene(sceneName); callBack?.Invoke(); }
LoadSceneAsync和DoLoadSceneAsync方法完成对协程的启动,通过AsyncOperation类获取Unity中常见异步加载的进度,并根据进度的完成情况,使用事件系统完成对UI动画的触发,根据游戏进度是否达到90%控制Loading动画的响应速度,最终在加载完毕后关闭Loading窗口。
public static void LoadSceneAsync(string sceneName, Action callBack = null) { MonoManager.Instance.StartCoroutine(DoLoadSceneAsync(sceneName, callBa ck)); } private static IEnumerator DoLoadSceneAsync(string sceneName, Action callBack = null) { AsyncOperation ao = UnityEngine.SceneManagement.SceneManager.LoadScen eAsync(sceneName); ao.allowSceneActivation = false; currentProgress = targetProgress = 0; while (ao.isDone == false) //在加载完成之前,指定Loading动画播放速度 { if (currentProgress > 0.9f) { targetProgress = 1f; while (currentProgress < targetProgress) { currentProgress += 0.003f; EventManager.EventTrigger<float>("LoadingSceneProgress", currentPro gress); //SetProgreeValue(currentProgress); yield return null; } } else { targetProgress = ao.progress; while (currentProgress < targetProgress) { //SetProgreeValue(currentProgress); EventManager.EventTrigger<float>("LoadingSceneProgress", currentPro gress); currentProgress += 0.01f; yield return null; } } if (currentProgress >= 1f) { //yield return new WaitForSeconds(1f); ao.allowSceneActivation = true; } yield return null; } EventManager.EventTrigger("LoadSceneSucceed"); callBack?.Invoke(); } }
2.2.4 配置系统
ConfigManager类供外部逻辑获取配置,包含GetConfig方法根据配置类型和列表ID获取持有的configSetting内的某个配置。
public T GetConfig<T>(string configTypeName, int id = 0) where T : ConfigBase { return configSetting.GetConfig<T>(configTypeName, id); }
ConfigBase类中无内容,其使用Sirenix.OdinInspector命名空间,继承序列化脚本可视化类,相当于中转,所有继承ConfigBase类的配置子类都可以使用CreateAssetMenu属性创建自定义的配置脚本文件,并在面板上可视化操作。
public class ConfigBase : SerializedScriptableObject { } [CreateAssetMenu(fileName = "ConfigSetting", menuName = " ConfigSetting")]
ConfigSetting是一类特殊的配置,其在游戏运行时全局唯一,通过面板拖拽装载所有游戏配置,并储存进字典,对ConfigManager提供GetConfig方法获取配置,经过配置类型和列表ID两层检验。
public T GetConfig<T>(string configTypeName, int id) where T : ConfigBase { // 检查类型 if (!configDic.ContainsKey(configTypeName)) { throw new System.Exception("配置设置中不包含这个Key:" + configTypeN ame); } // 检查ID if (!configDic[configTypeName].ContainsKey(id)) { throw new System.Exception($"配置设置中{configTypeName}不包含这个 ID:{id}"); } // 说明一切正常 return configDic[configTypeName][id] as T; }
GameSetting是一类框架层面的特殊配置,其并不存储游戏数据,而是持有对象池和UI的配置数据,在Unity编译前通过反射遍历所有程序集下的每一个类型,识别所有属性标记,并根据属性将需要受对象池管理的类型录入对象池缓存字典并将UI预制体进行加载、缓存、分层,以对象池为例。
System.Reflection.Assembly[] asms = AppDomain.CurrentDomain.GetAssembl ies(); // 遍历程序集 foreach (System.Reflection.Assembly assembly in asms) { // 遍历程序集下的每一个类型 Type[] types = assembly.GetTypes(); foreach (Type type in types) { // 获取pool特性 PoolAttribute pool = type.GetCustomAttribute<PoolAttribute>(); if (pool != null) { cacheDic.Add(type, true); } } }
2.2.5 资源服务
ResManager类中首先通过Init方法在GameRoot初始化所有管理器时,通过GameRoot下的GameSetting获得对象池缓存字典。
public override void Init() { base.Init(); wantCacheDic = GameRoot.Instance.GameSetting.cacheDic; cacheDic.Add(type, true); }
通过LoadAssest方法调用Resources.Load方法从指定资源路径加载Unity资源,如AudioClip、Sprite。
public T LoadAsset<T>(string path) where T : UnityEngine.Object { return Resources.Load<T>(path); }
通过LoadGameObject方法加载游戏对象,在加载资源后,调用对象池管理器负责实例化,若有从对象池取,没有再Instantiate克隆一个。
public GameObject LoadGameObject(string path) { GameObject go = Resources.Load<GameObject>(path); return PoolManager.Instance.GetGameObject(go); }
类似的,使用Load<T>方法加载普通类对象,调用对象池管理器负责实例化,经过重载后可以传入预制体路径来获取某个游戏对象上的脚本组件。
public T Load<T>() where T : class, new() { // 需要缓存 if (CheckCacheDic(typeof(T))) { return PoolManager.Instance.GetObject<T>(); } else { return new T(); } }
类似的,使用Load<T>方法加载普通类对象,调用对象池管理器负责实例化,经过重载后可以传入预制体路径来获取某个游戏对象上的脚本组件。 异步加载过程中同样先检查对象池中是否存在,不存在再开启协程调用Resources.LoadAsync方法异步加载游戏对象或Unity资源,由于异步加载不确定对象什么时候能够获得,无法直接通过返回值获得加载的结果,故通过方法回调的方式,直接将要执行的功能通过Action<T>传入,相当于将原来整个功能逻辑传给协程,待加载完毕后直接执行,这时加载的结果已经隐含在Action中方法的形参部分,加载的类型通过泛型T控制,这里的T受到UnityEngine.Object约束,不是普通的脚本类对象,脚本类对象由Load<T>负责。
IEnumerator DoLoadGameObjectAsync<T>(string path, Action<T> callBack = nu ll, Transform parent = null) where T : UnityEngine.Object { ResourceRequest request = Resources.LoadAsync<GameObject>(path); yield return request; GameObject go = InstantiateForPrefab(request.asset as GameObject, parent); callBack?.Invoke(go.GetComponent<T>()); }
GetPrefab方法用于直接获得一个预制体,与LoadGameObject方法不同,其直接获得预制体本身,而LoadGameObject方法是基于加载的预制体进行实例化,适用于重复加载的物体对象。
public GameObject GetPrefab(string path) { GameObject prefab = Resources.Load<GameObject>(path); if (prefab != null) { return prefab; } else { throw new Exception("预制体路径有误,没有找到预制体"); } }
2.2.6 音效服务
音效服务借助Odin插件中的OnValueChanged特性,其可以在Inspector面板上的值发生变化时自动执行相应的方法更新音量属性,以更新全局音量为例,会自动调用UpdateAllAudioPlay方法更新音量,结合C#的属性,通过get,set方法实现对音量的实时读写封装,在属性值变化时自动调用相应的更新方法。
[Range(0, 1)] [OnValueChanged("UpdateAllAudioPlay")] private float globalVolume; public float GlobalVolume { get => globalVolume; set { if (globalVolume == value) return; globalVolume = value; UpdateAllAudioPlay(); }
UpdateBGAudioPlay及相关更新音量的方法通过音量属性对背景音乐和特效音乐上的AudioSource进行更新,支持音量变化、静音、暂停、空间音效,供播放音乐的上层逻辑调用。特效音乐全局部不唯一,不通过列表保存,以特效音乐的更新为例。
private void UpdateEffectAudioPlay(float spatial = -1) { // 倒序遍历 for (int i = audioPlayList.Count - 1; i >= 0; i--) { if (audioPlayList[i] != null) { SetEffectAudioPlay(audioPlayList[i],spatial); } else { audioPlayList.RemoveAt(i); } } } private void SetEffectAudioPlay(AudioSource audioPlay, float spatial = -1) { audioPlay.mute = isMute; audioPlay.volume = effectVolume * globalVolume; if (spatial != -1) { audioPlay.spatialBlend = spatial; } if (isPause) { audioPlay.Pause(); } else { audioPlay.UnPause(); } }
播放背景音乐的PlayBGAudio方法经过重载后支持默认、指定AudioClip、指定路径播放,以指定资源为例。
public void PlayBGAudio(AudioClip clip, bool loop = true, float volume = -1) { BGAudioSource.clip = clip; IsLoop = loop; if (volume != -1) { BGVolume = volume; } BGAudioSource.Play(); }
由于特效音乐存在多类且重复使用的情况,需要与对象池联用提升性能。播放时先调用GetAudioPlay方法从对象池中获取获生成一个AudioPlay作为特效音乐播放的载体,通过设置其AudioSource属性播放特效音乐,在这里使用is3D和IsSpatial标志量来控制UI元素这类不需要空间音效的特效音乐不受到正常物体音效的影响,经过重载后特效音乐支持在指定物体、指定位置根据AudioClip或资源路径生成。在播放完成后调用RecycleAudioPlay方法执行协程将AudioPlay放回对象池待下次调用,支持播放完成后的方法回调,以在某组件上播放一次音效为例。
public void PlayOnShot(AudioClip clip, Vector3 position, float volumeScale = 1, b ool is3d = true, UnityAction callBack = null, float callBacKTime = 0) { // 初始化音乐播放器 AudioSource audioSource = GetAudioPlay(is3d && IsSpatial); audioSource.transform.position = position; // 播放一次音效 audioSource.PlayOneShot(clip, volumeScale); // 播放器回收以及回调函数 RecycleAudioPlay(audioSource, clip, callBack, callBacKTime); } private AudioSource GetAudioPlay(bool is3D = true) { // 从对象池中获取播放器 AudioSource audioSource = PoolManager.Instance.GetGameObject<AudioSour ce>(prefab_AudioPlay); SetEffectAudioPlay(audioSource, is3D ? 1f : 0f); audioPlayList.Add(audioSource); return audioSource; } private IEnumerator DoRecycleAudioPlay(AudioSource audioSource, AudioClip c lip, UnityAction callBak, float time) { // 延迟 Clip的长度(秒) yield return new WaitForSeconds(clip.length); // 放回池子 if (audioSource != null) { audioSource.JKGameObjectPushPool(); // 回调 延迟 time(秒)时间 yield return new WaitForSeconds(time); callBak?.Invoke(); } }
2.2.7 托管Mono服务
MonoManager内的若干方法以Action核心,将需要托管的功能方法添加/移除其持有的Action容器中,再由其生命周期函数完成统一调用,以Update方法为例。
public void AddUpdateListener(Action action) { updateEvent += action; } public void RemoveUpdateListener(Action action) { updateEvent -= action; } private void Update() { updateEvent?.Invoke(); }
普通C#类调用协程的功能集成在拓展方法服务中,通过对象.的方式直接借助MonoManager这一Mono类的对象启动/关闭协程,可以更方便地进行调用,见下一小节。
2.2.8 拓展方法和自定义特性服务
核心类Extension使用拓展方法将自定义特性、对象池管理、托管服务中的常用功能进行进一步调用简化,使其成为对象本身的一类方法。GetAttribute方法调用Unity的自定义特性方法获取对象上具有的特性,且重载支持多个特性中根据type获得指定特性。
public static T GetAttribute<T>(this object obj) where T : Attribute { return obj.GetType().GetCustomAttribute<T>(); }
GameObjectPushPool和ObjectPushPool方法调用PoolManager中的PushGameObject方法和PushObject方法将游戏对象或普通类对象作为参数放入对象池。
public static void GameObjectPushPool(this GameObject go) { PoolManager.Instance.PushGameObject(go); } public static void ObjectPushPool(this object obj) { PoolManager.Instance.PushObject(obj); }OnUpdate和RemoveUpdate等方法调用MonoManager内的生命周期函数相关方法完成托管服务并以MonoMananger的单例作为载体开启或关闭协程。public static void OnUpdate(this object obj, Action action) { MonoManager.Instance.AddUpdateListener(action); } public static void RemoveUpdate(this object obj, Action action) { MonoManager.Instance.RemoveUpdateListener(action); } public static Coroutine StartCoroutine(this object obj, IEnumerator routine) { return MonoManager.Instance.StartCoroutine(routine); } public static void StopCoroutine(this object obj, Coroutine routine) { MonoManager.Instance.StopCoroutine(routine); }
2.2.9 事件系统
在EventManager中,数据底层使用事件字典对数据进行存储,每个String Key对应的事件信息类使用多元泛型重载类本身,支持无参到最多三个参数的Action事件,为了便于用字典统一收录这些事件类,使用IeventInfo接口作为这些类的顶层代表,体现了多态。
private class EventInfo : IEventInfo { public Action action; public void Init(Action action) { this.action = action; } public void Destory() { action = null; this.ObjectPushPool(); } }
AddEventListener方法添加事件监听时,首先检查事件字典中有无对应的Key记录,有则直接将action合并,没有需要从对象池中新取出一个对应的EventInfo类进行初始化Action赋值,再将其加入到事件字典中,调用事件字典中的EventInfo类时由于不确定参数的个数,需要根据功能进行强制转换,以无参事件监听的添加为例。
public static void AddEventListener(string eventName, Action action) { // 有没有对应的事件可以监听 if (eventInfoDic.ContainsKey(eventName)) { (eventInfoDic[eventName] as EventInfo).action += action; } // 没有的话,需要新增到字典中,并添加对应的Action else { EventInfo eventInfo = PoolManager.Instance.GetObject<EventInfo>(); eventInfo.Init(action); eventInfoDic.Add(eventName, eventInfo); } }
RemoveEventListener方法移除事件监听时,先释放字典中对应的事件对象资源,再从字典列表中移除记录即可。
public static void RemoveEventListener(string eventName) { if (eventInfoDic.ContainsKey(eventName)) { eventInfoDic[eventName].Destory(); eventInfoDic.Remove(eventName); } }
EventTrigger方法触发事件时,在事件字典中直接取出事件接口强转成相应的事件类型,执行Invoke方法即可,以无参事件的触发为例。
public static void EventTrigger(string eventName) { if (eventInfoDic.ContainsKey(eventName)) { (eventInfoDic[eventName] as EventInfo).action?.Invoke(); } }
2.2.10 UI框架实现
在UIManager中,首先实现层级管理类UILayer,用于解决多层UI重叠时交互混乱的问题,只允许最上层进行交互,将整个UILayer类序列化可视暴露到Inspector面板上,提前添加好层级对象和遮罩Image组件,使用count计数当前层内的窗口数量,打开或关闭窗口时自动更新当前层状态,如果层内还有窗口,则打开遮罩组件的射线检测,开启交互,没有窗口则关闭,不会影响其他层的交互。最新进入的窗口层级最接近屏幕,使用当前层的子物体数量计算可得遮罩层应该置于倒数第二的位置,这样可以保证每一层内只有最接近屏幕的层级开启交互且层与层之间只有最上层的开启交互,Layer本身不用添加Image组件,其会在自身的Mask被挡住。
private class UILayer { public Transform root; public Image maskImage; private int count = 0; public void OnShow() { count += 1; Update(); } public void OnClose() { count -= 1; Update(); } private void Update() { maskImage.raycastTarget = count != 0; int posIndex = root.childCount - 2; maskImage.transform.SetSiblingIndex(posIndex < 0 ? 0 : posIndex); } }
UI元素库会在调用时通过属性get方法从GameRoot中获取配置系统中初始化好的UI窗口。
public Dictionary<Type, UIElement> UIElementDic { get { return GameRoot.Instance.GameSetting.UIElementDic; } }
显示窗口的Show方法使用泛型做窗口类型指定,形参layer指定层级,做一层封装符合使用习惯。显示窗口时需要从UI库中查找,若参数中不指定层级,则默认使用UI特性中的层级,当窗口实例已经存在时,说明开启了缓存,直接将未激活的窗口对象重新激活并设置层级和位置即可,并调用窗口本身的显示方法执行功能逻辑,若无则需要获取实例并设置位置,再调用窗口本身的初始化方法,最后设置Layer的遮罩关系。
public UI_WindowBase Show(Type type, int layer = -1) { if (UIElementDic.ContainsKey(type)) { UIElement info = UIElementDic[type]; int layerNum = layer == -1 ? info.layerNum : layer; // 实例化实例或者获取到实例,保证窗口实例存在 if (info.objInstance != null) { info.objInstance.gameObject.SetActive(true); info.objInstance.transform.SetParent(UILayers[layerNum].root); info.objInstance.transform.SetAsLastSibling(); info.objInstance.OnShow(); } else { UI_WindowBase window = ResManager.Instance.InstantiateForPrefab(info.prefab, UILayers[layerNum].root).GetComponent<UI_WindowBase>(); info.objInstance = window; window.Init(); window.OnShow(); } info.layerNum = layerNum; UILayers[layerNum].OnShow(); return info.objInstance; } // 资源库中没有意味着不允许显示 return null; }
关闭窗口的Close方法同样使用泛型做窗口类型指定,形参layer指定层级,做一层封装符合使用习惯。关闭窗口时需要从UI库中查找,先取消事件监听,再根据是否缓存设置对象隐藏或销毁,最终更新Layer遮罩关系。
public void Close(Type type) { if (UIElementDic.ContainsKey(type)) { UIElement info = UIElementDic[type]; if (info.objInstance == null) return; //取消事件监听(额外的内容) info.objInstance.OnClose(); // 缓存则隐藏 if (info.isCache) { info.objInstance.transform.SetAsFirstSibling(); info.objInstance.gameObject.SetActive(false); // Debug.Log(1); } // 不缓存则销毁 else { Destroy(info.objInstance.gameObject); info.objInstance = null; } UILayers[info.layerNum].OnClose(); } }
在UI_WindowBase窗口基类中,实现了窗口本身在打开/关闭时执行的额外逻辑,如事件的监听、本地化等,使用枚举来说明窗口结果类型,向窗口子对象提供了可供重写的OnShow、RegisterEventListener、CancelEventListener虚方法,使得窗口在实现通用功能(如语言更新等)的基础上定制化执行自己的功能。
public virtual void OnShow() { RegisterEventListener(); } public virtual void OnClose() { CancelEventListener(); } protected virtual void RegisterEventListener() { EventManager.AddEventListener("UpdateLanguage", OnUpdateLanguage); } protected virtual void CancelEventListener() { EventManager.RemoveEventListener("UpdateLanguage", OnUpdateLanguage); }
UI_LoadingWindow是实际的窗口,继承自UI_WindowBase,通过上述功能很好地将控制逻辑和UI元素解耦,其功能是根据异步加载的进度刷线Loading显示的进度条,在窗口打开时,会先调用WindowBase中的通用开启逻辑,注册对应的监听方法,更新UI进度条为0,关闭时取消事件的监听注册。
public override void OnShow() { base.OnShow(); UpdateProgress(0); } protected override void RegisterEventListener() { base.RegisterEventListener(); EventManager.AddEventListener<float>("LoadingSceneProgress", UpdateProgress); EventManager.AddEventListener("LoadSceneSucceed", OnLoadSceneSucceed); } protected override void CancelEventListener() { base.CancelEventListener(); EventManager.RemoveEventListener<float>("LoadingSceneProgress", UpdateProgress); EventManager.RemoveEventListener("LoadSceneSucceed", OnLoadSceneSucceed); }
3.程序框架应用
3.1 游戏简介
《ISLAND》是开发者ParKS使用程序框架后制作的一款2.5D的俯视角第三人称射击游戏Demo,以程序框架作为底层架构,实现了基本独立游戏的常见功能,包括人物移动、射击、技能使用、敌人生成、关卡加载、进度保存、UI交互等,本节将针对框架在具体游戏逻辑中的功能进行介绍,其余功能如敌人AI、场景搭建省略。
3.2 基于框架的总体设计
以不同的Scene场景作为开发的过程划分依据,在挂载程序框架GameRoot的情况下,设计了如图3.1所示的结构图,在游戏主菜单场景中,主要是UI交互,存在主菜单管理器,GameRoot下挂游戏管理器GameManager,负责控制游戏状态并保存用户数据。游玩场景中除环境物体外,有游戏人物和摄像机的结合对象并挂载有整体的游玩逻辑。

图3.1 游戏总体设计结构图
3.3 框架应用实现
本小节以事件系统、UI框架、对象池、存档系统、配置系统作为主要案例介绍,其他系统的功能内含在案例中调用,故不进行单独说明。
3.3.1 事件系统
事件系统在游戏逻辑中主要用于管理玩家的状态变更及其对应发生的UI变化,以玩家的控制脚本为例,使用事件系统可在玩家对象初始化时,在事件中心添加武器切换、传送、护盾、电力、血量、受伤等功能方法的监听。
void AddListener() { EventManager.AddEventListener("ChangeWeapon", ChangeWeapon); EventManager.AddEventListener("Teleport", Teleport); EventManager.AddEventListener("Block", Block); EventManager.AddEventListener("Shield", Shield); EventManager.AddEventListener("Light", Light); EventManager.AddEventListener("Electrical", Electrical); EventManager.AddEventListener("EndMask", EndMask); EventManager.AddEventListener("EndMutex", EndMutex); EventManager.AddEventListener<float>("PlayerHurt", PlayerHurt); }
以切换武器为例,在玩家对应按键响应调用切换武器的方法时,使用事件中心触发“ChangeWeapon”事件,需要注意的是,触发武器切换的逻辑和武器切换的逻辑并不在同一个脚本中,所以如果不使用事件系统,则必须需要先获得武器切换方法所在的对象实例,使得二者耦合性大大增加,我们希望在开发过程中,使用武器切换逻辑的功能方法不必关注其实例是否存在,只是将这个需求告知事件中心,由事件中心完成功能的触发。
public void ChangeWeaponStart(float dir) { if (_canChange) { EventManager.EventTrigger("ChangeWeapon"); _gun.gameObject.SetActive(false);//隐藏当前模型 …….. } }
3.3.2 UI框架
UI框架完成了对窗口的打开关闭以及内部的分层缓存机制的功能。以主菜单窗口MainMenu为例,如图3.2所示。

图3.2 MainMenu主菜单UI结构图
MainMenu的窗口对象挂载在GameRoot下的UIRoot的Layer0层,这是由UI特性决定的,指定了其不需要缓存,通过指定路径加载资源prefab。
[UIElement(false, "UI/UI_MainMenuWindow", 0)]
在Hierarchy面板中,有MainMenuManager用于在游戏运行开始时调用UIManager的Show方法打开主菜单UI窗口,并播放背景音乐。
void Start() { …… // 关闭全部UI UIManager.Instance.CloseAll(); GameObject.Find("GameRoot").GetComponent<AudioListener>().enabled = tru e; UIManager.Instance.Show<UI_MainMenuWindow>(); AudioManager.Instance.PlayBGAudio(Resources.Load<AudioClip>("Audio/B G/BGAudio")); }
在窗口同名的核心类UI_MainMenuWindow类中,持有许多UI元素如按键、滑动条、文本框等,这些元素发生改变时对应的数据都需要进行更新,故都需要将这些事件的监听添加到事件中心。
protected override void RegisterEventListener() { base.RegisterEventListener(); //Enter按钮监听添加 EnterButton.onClick.AddListener(OnEnter); ///Continue界面 //添加进入游戏的监听 EventManager.AddEventListener<SaveItem, UserData>("EnterGame", EnterGa me); ….. }
到目前为止,UI窗口对象已经通过UI框架按层级加载到场景中并添加好了事件监听,其中隐含了框架的执行逻辑使得不必在UI_MainMenuWindow类中显示的重写OnShow并调用RegisterEventListener方法,UI框架会自动完成,如下。
使用UIManager打开窗口时 UIManager.Instance.Show方法会调用窗口的OnShow方法。
public UI_WindowBase Show(Type type, int layer = -1) { …… window.OnShow(); …… }
但由于UI_MainMenuWindow类中并没有重写OnShow方法所以实际执行的是继承自WindowBase中隐含的Onshow方法。
public virtual void OnShow() { RegisterEventListener(); }
虽然功能和WindoBase中的Onshow一致,但此时RegisterEventListener调用的实际是子类UI_ManMenuWindow中的监听注册方法,完成了监听的自动调用,而这个过程对开发者来说是不必关心的,只需要在子类的监听注册方法中填写逻辑并通过base.RegisterEventListener调用通用的监听功能注册。UI窗口的关闭也是类似,会在窗口关闭时自动完成事件监听的移除,完全由框架来负责。
结合配置系统和GameRoot中的Editor模式代码,会在游戏真正运行前就提前将UI窗口加载完毕并可视化呈现出来,如图3.3所示。

图3.3 UI窗口运行前配置图
3.3.3 对象池
武器射击的子弹这类需要重复使用的物体,需要受到对象池管理,在射击逻辑启用时,会调用资源系统加载出子弹的预制体,并以预制体为依据调用对象池系统实例化出预制体的对象,在这一过程中会自动将子弹记录进对象池字典,并在子弹销毁时放回对象池,实现资源的重复利用。
public void GetBullet() { clone = PoolManager.Instance.GetGameObject<Bullet>(bullet).gameObject; …… }
3.3.4 存档系统
存档SaveItem类作为重复使用的一类数据,其本身具有Pool特性受到对象池的管理,存档类在主界面的可视化如图3.4所示,在游戏运行时,GameManager会读取其中存储的全局设置进行音量初始化。
private void Start() { UserSetting = SaveManager.LoadSetting<UserSetting>(); // 如果没有获取到用户设置,意味着首次进入游戏或玩家删除了设置文件 if (UserSetting == null) { UserSetting = new UserSetting(0.5f, 0.5f, 0.5f, false, true); SaveManager.SaveSetting(UserSetting); } // 根据用户设置,设置相关的内容 AudioManager.Instance.GlobalVolume = UserSetting.GlobalVolume; AudioManager.Instance.EffectlVolume = UserSetting.EffectVolume; AudioManager.Instance.BGVolume = UserSetting.BGVolume; AudioManager.Instance.IsMute = UserSetting.IsMute; AudioManager.Instance.IsSpatial = UserSetting.IsSpatial; }
在主菜单场景中,进入游戏会创建存档和用户数据,并通过存档系统更新本地数据。
private void CreatNewSaveAndEnterGame(string userName) { //标记为新游戏 GameManager.Instance.IsNewGame = true; // 建立存档 SaveItem saveItem = SaveManager.CreateSaveItem(); // 创建首次存档时的用户数据 UserData userData = new UserData(userName); SaveManager.SaveObject(userData, saveItem); // 进入游戏 EnterGame(saveItem, userData); }
进一步saveItem和UserData会赋值给GameManager进行统一管理,在游戏运行的过程中,GameManager会实时监听数据是否发生改变,若发生改变则更新存档,如玩家的分数、位置。
public void UpdateScore(int score) { // 保存当前得分 UserData.Score = score; SaveManager.SaveObject(UserData, SaveItem); //// 刷新主界面存档显示 //EventManager.EventTrigger("UpdateSaveItem"); } public void UpdatePos(float x, float y, float z) { UserData.UpdatePos(x, y, z); SaveManager.SaveObject(UserData, SaveItem); }

图3.4 存档可视化
3.3.5 场景系统
在主菜单的进入游戏按钮触发后,会关闭当前场景内的所有UI窗口并调用UIManager显示Loading窗口,此时后台会调用场景系统中的异步加载方法载入游戏场景,此场景资源较多,待异步加载进度达到100%加载完毕后会关闭Loading窗口,如图3.5所示。
private void EnterGame(SaveItem saveItem, UserData userData) { UIManager.Instance.CloseAll(); //显示加载面板 UIManager.Instance.Show<UI_LoadingWindow>(); GameManager.Instance.EnterGame(saveItem, userData); //异步加载场景 SceneManager.LoadSceneAsync("GameDemo_Final"); }

图3.5 场景切换Loading界面
3.4 性能优化
在Unity下资源的工作流程分为导入、创建、构建、分发、加载五个部分,主要针对导入过程中静态资源进行优化,使用Unity Asset Checker对资源进行检测,检测结果如图3.6所示,其中有关音频、模型、动画等资源的导入设置提供了许多优化意见。

a) 资源检测结果

b) 资源检测结果
图3.6 资源检测结果
经过性能优化后,使用Unity UPR工具进行性能检测,对比Unity官方Demo的测试结果来看,框架本身具有良好的性能优势,对比如图3.7所示。

a) 框架实际应用性能评价结果

b) Unity官方Demo性能评价结果
图3.7性能对比结果
4.未来工作
目前程序框架除以上提出的内容外,已经实现了事件工具、状态机、本地化功能,待后续完善相关文档补充。
目前的程序框架在资源管理方面还有较大的完善空间,正在结合Addressable Assets System对资源加载部分进行优化。
发布于技术交流
3条评论

问
AI
全新AI功能上线
1. 基于Unity微调:专为Unity优化,提供精准高效的支持。
2. 深度集成:内置于团结引擎,随时查阅与学习。
3. 多功能支持:全面解决技术问题与学习需求。

问
AI