> 文档中心 > 【UnityGamePlay】Creator Kit - RPG 代码分析(1)-核心框架、单例、ECS、定时事件

【UnityGamePlay】Creator Kit - RPG 代码分析(1)-核心框架、单例、ECS、定时事件


Creator Kit - RPG 简介

Unity 官方的几个教程代码之一,适合入门学习。
实现了多个模块,本系列就逐步学习一下这个项目的源码。

Core

核心模块,主要是实现一些框架层功能,这里主要实现了 全局单例、实体追踪、事件系统

全局单例

根据类型注册全局单例,实现比较简单,直接看代码就懂了

namespace RPGM.Core{    ///     /// A static class which maps a type to a single instance.    ///     ///     static class InstanceRegister<T> where T : class, new()    { public static T instance = new T();    }}

如果想实现一个单例类,提供一个无参默认构造函数,之后使用InstanceRegister就能注册一个全局单例。后面的使用也用InstanceRegister.instance 来使用。

注意类后面的 where T : class, new() 这个语法,where T: 表示对 T 的限制,后面加上限制条件,class 表示T 必须是个类,不能是接口、结构体等,new() 表示T 必须实现无参构造函数,类似用法如下。

where T : struct // T must be a structwhere T : new()  // T must have a default parameterless constructorwhere T : IComparable // T must implement the IComparable interface

实体追踪

这个也比较短,先上代码。

namespace RPGM.Core{    ///     /// Monobehavioids which inherit this class will be tracked in the static     /// Instances property.    ///     ///     public class InstanceTracker<T> : MonoBehaviour where T : MonoBehaviour    { public static List<T> Instances { get; private set; } = new List<T>(); int instanceIndex = 0; protected virtual void OnEnable() {     instanceIndex = Instances.Count;   // 下标,第一个是0     Instances.Add(this as T); } protected virtual void OnDisable() {     if (instanceIndex < Instances.Count)     {  var end = Instances.Count - 1;  Instances[instanceIndex] = Instances[end];  Instances.RemoveAt(end);     } }    }}

我理解有点儿 ECS模式 的感觉,如果不懂ECS可以去了解一下,这里稍微指个路:ECS概述

如果想遍历所有拥有该组件的实体,就可以用这个InstanceTracker 类来实现。只看这个类不容易理解为什么这么写,一会儿看一个例子就知道了,不过现在我还是要先分析一下这个类的功能。

看类的第一行,有个公有静态成员 Instances ,这是一个存储T 的列表,因为静态,所以是唯一的。(ps: List 的底层实现是数组)

这个类继承了 MonoBehaviour,也就是会使用Mono的生命周期,然后想一想 OnEnable()OnDisable() 会在什么时候调用,这样是不是就逐渐理解这个类的意思了?

如果不知道在什么时候调用,可以先去学习一下MonoBehaviour 的生命周期,这个有太多人写过了,我就不copy了。

Mono脚本的执行顺序是 Awake -> OnEnable -> Start -> Update ,接下来的这句话是过不了查重了…

OnEnable 在每次脚本对象由 disabled 到 enabled,或挂载的对象由 inactive 到 active 时,都会执行。( 但如果脚本本身是 disable 的,对象由 inactive 到 active 也不会执行 OnEnable )

总之呢,可以理解为OnEnable 会在脚本对象能使用的时候就执行。

继续看代码,OnEnable() 中,给下标赋值,把这个对象添加到 Instances 列表中。这样,所有的同种 T 对象,就都在这个列表里了,如果想知道游戏中所有具备T 类的物体,就可以从这个类获得。

OnDisable() 中就是把这个实例从 列表中删除。注意List 的底层实现是数组,然后就理解为什么要这样删除了吧。

如果上面没看懂,接下来看一个例子,再看一遍上面的就能看懂了。


接下来看个实例理解这个实例追踪,有个2D游戏,需求是角色走到房子后面的时候,把房子变成半透明的。

实现思路也很简单,角色的触发器和房子的触发器 触发的时候,就设置房子的透明度。

明白了需求和实现方法,下面这两段代码就能看懂了。

  • FadingSprite.cs
[RequireComponent(typeof(SpriteRenderer), typeof(Collider2D))]   // 必须有这两个组件,这个脚本才能挂到物体上    public class FadingSprite : InstanceTracker<FadingSprite>    { internal SpriteRenderer spriteRenderer; internal float alpha = 1, velocity, targetAlpha = 1; void Awake() {     spriteRenderer = GetComponent<SpriteRenderer>(); } void OnTriggerEnter2D(Collider2D other) {     targetAlpha = 0.5f; } void OnTriggerExit2D(Collider2D other) {     targetAlpha = 1f; }    }
  • FadingSpriteSystem.cs
///     /// A system for batch animation of fading sprites.    ///     public class FadingSpriteSystem : MonoBehaviour    { void Update() {     foreach (var c in FadingSprite.Instances)     {  if (c.gameObject.activeSelf)  {      c.alpha = Mathf.SmoothDamp(c.alpha, c.targetAlpha, ref c.velocity, 0.1f, 1f);  // 平滑改变,注意 ref c.velocity ,不能是个同级的局部变量,具体用法可以自己搜一下      c.spriteRenderer.color = new Color(1, 1, 1, c.alpha);  // 第四个参数是阿尔法通道值,代表透明度  }     } }    }

把 FadingSprite.cs 挂在想要实现触发透明效果的物体上,通过targetAlpha 调整透明度,这有点ECS的味儿了吧,这个脚本就相当于Entity,只有数据,FadingSpriteSystem.cs 就是 System。

FadingSprite.cs 实现当触发的时候透明度设为0.5,不触发的时候透明度设为1,也就是不透明,在FadingSpriteSystem.cs 中的Update() 中通过InstanceTracker 遍历所有具有透明效果的物体,设置alpha 值。

感觉上面有点啰嗦了,这么一个小功能说了这么多,后面还有很多呢…

一行行分析代码不可取,后面就把细节都写在代码注释里吧,只把关键节点说一下。

关于实体追踪就先到这里了。

定时事件系统

实现了一个简单的定时事件系统

优先级队列

用堆实现了个优先级队列,这里就不贴源码了,因为代码太长,而且优先级队列有多种实现方式,这里实现的没什么独特的,所以就不贴了。

不过说说堆怎么实现优先级队列吧,队列有Push()Pop() 操作,堆是用数组来模拟二叉树, n的两个子节点是 2n 和 2n+1,父节点是 n/2 。

Push() 操作就是把 值放到数组末尾,之后循环和父节点比较,如果大于父节点就和父节点替换(假设是个最大堆),一直到小于父节点或者替换到根节点的时候。这样保证这个二叉树的父节点一点比子节点大,但是左右同级子节点之间不能保证谁大谁小。

Pop() 操作是,把根节点,也就是最大值和数组末尾值替换,同时取出最大值。现在根节点存的是数组末尾值,比较小,需要做下沉操作,循环和左右子节点比较,如果小于某个子节点,就和子节点中较大的值替换,一直到大于左右两个子节点,或者到了叶子结点才停止。这样最后就能沉到适合的位置,根节点还是最大值。

事件类

直接上代码

namespace RPGM.Core{    ///     /// An event allows execution of some logic to be deferred for a period of time.    ///     ///     public abstract class Event : System.IComparable<Event>    { public virtual void Execute() { } protected GameModel model = Schedule.GetModel<GameModel>();     // 游戏全局管理 internal float tick;    // 定时时间 public int CompareTo(Event other)// 实现比较函数,优先级队列比较使用,时间最近的优先级最高 {     return tick.CompareTo(other.tick); } internal virtual void ExecuteEvent() => Execute(); internal virtual void Cleanup() { }    }    ///     /// Add functionality to the Event class to allow the observer / subscriber pattern.    ///     ///     public abstract class Event<T> : Event where T : Event<T>    { public static System.Action<T> OnExecute; internal override void ExecuteEvent() {     Execute();     OnExecute?.Invoke((T)this); }    }}

这个看着代码就能理解,没啥说的,说几个注意点吧。

GameModel 是GamePlay 的全局控制,到后面分析GamePlay 的时候再说。

tick 是定时时间,比如延时10s执行,这个值就是10(ps:如果单位是秒的话)

OnExecute?.Invoke((T)this); 中的?. 运算符表示如果OnExecute() 不为空的话就执行它的Invoke()方法。

Invoke() 是 Action 的调用方式,Action 可以理解成回调函数。

调度类

这里面主要是对事件进行调度。

主要做的事就是:把事件放到一个优先级队列里,事件到了就执行事件方法。

这个文件代码较长,所以逐个方法来分析。

先看一段和事件调度关系不大的代码

 <summary>/// Return the simulation model instance for a class.  /// /// static public T GetModel<T>() where T : class, new(){    return InstanceRegister<T>.instance;}/// /// Set a simulation model instance for a class. Uses reflection/// to preserve existing references to the model./// /// static public void SetModel<T>(T instance) where T : class, new(){    var singleton = InstanceRegister<T>.instance;    foreach (var fi in typeof(T).GetFields())   // GetFields() 取得该类的成员变量信息    { fi.SetValue(singleton, fi.GetValue(instance));  // 遍历这个对象的所有字段,给 singleton 的这个字段赋值,赋的值就是参数instance 的这个字段, // 这是同一个类型,相当于 singleton = instance,那我问题来了,为什么不直接赋值呢,是因为没有实现拷贝函数吗? // 查了一下才知道,原来C#没有拷贝函数,类默认是引用类型,直接赋值的话相当于引用赋值,不可取,要实现拷贝函数的话可以使用ICloneable 接口    }}/// /// Destroy the simulation model instance for a class./// /// static public void DestroyModel<T>() where T : class, new(){    InstanceRegister<T>.instance = null;}

这三个主要是使用 InstanceRegister 获得类单例,其中SetModel 利用反射,把参数instance 赋值给单例singleton。用反射就不用给这个类实现Clone 接口了。

接下来说事件调度,重点看这两个成员

static HeapQueue<Event> eventQueue = new HeapQueue<Event>();static Dictionary<System.Type, Stack<Event>> eventPools = new Dictionary<System.Type, Stack<Event>>();

eventQueue 是所有类型事件的优先级队列,以时间最快到达的优先级最高。
eventPools 是一个事件对象池,这里的每个事件都是一个类,每个类实现自己的ExecuteEvent() 方法,这个对象池就是存储的这些类的对象,避免频繁创建销毁对象。

创建事件

/// /// Create a new event of type T and return it, but do not schedule it. 只创建不调度,这个应该设置为private 吧/// /// /// static public T New<T>() where T : Event, new(){    Stack<Event> pool;    if (!eventPools.TryGetValue(typeof(T), out pool))   // 如果池子不存在就创建一个    { pool = new Stack<Event>(32); pool.Push(new T()); eventPools[typeof(T)] = pool;    }    if (pool.Count > 0) return (T)pool.Pop();    else return new T();}/// /// Schedule an event for a future tick, and return it./// /// The event./// Tick./// The event type parameter.static public T Add<T>(float tick = 0) where T : Event, new(){    var ev = New<T>();    ev.tick = Time.time + tick;    eventQueue.Push(ev);    return ev;}/// /// Reschedule an existing event for a future tick, and return it./// /// The event./// Tick./// The event type parameter.static public T Add<T>(T ev, float tick) where T : Event, new(){    ev.tick = Time.time + tick;    eventQueue.Push(ev);    return ev;}

这三个方法,New 使用对象池,只创建不调度,调用Add 后才会把事件加入调度队列进行调度。

这两个Add 方法,主要区别是:第一个尝试使用对象池,第二个不使用对象池。

调度事件

/// /// Tick the simulation. Returns the count of remaining events./// If remaining events is zero, the simulation is finished unless events are/// injected from an external system via a Schedule() call./// /// static public int Tick(){    var time = Time.time;    var executedEventCount = 0;    while (eventQueue.Count > 0 && eventQueue.Peek().tick <= time)      // 事件队列有数据,并且队首的时间已经到了    { var ev = eventQueue.Pop(); var tick = ev.tick; ev.ExecuteEvent(); if (ev.tick > tick) // 可能会在 ExecuteEnent() 里把ev.tick 延后了 {     //event was rescheduled, so do not return it to the pool. } else     {     // Debug.Log($"{ev.tick} {ev.GetType().Name}");     ev.Cleanup();     try     {  eventPools[ev.GetType()].Push(ev);      // 把事件对象重新放回事件对象池     }     catch (KeyNotFoundException)     {  Debug.LogError($"No Pool for: {ev.GetType()}");     } } executedEventCount++;    }    return eventQueue.Count;    // 返回还未执行的事件数量}

这个逻辑就是把事件队列中到达定时时间的事件取出来,执行它的ExecuteEvent() 方法,这也是个定时器。

执行完后把该事件对象放入对象池,如果下次再调用Add 就不用重复创建对象了,注意这个 try...catch...

上面说过,第二个Add 方法是不使用对象池的,再看看New 方法,也就是说如果一直没有创建这个对象池,这里吧对象放入对象池就会操作异常。

不过既然提供了第二个Add 方法,这应该是预料之中的操作吧,为什么打个Error 日志嘞。如果必须使用对象池,完全没有必要提供第二个Add 方法。

好吧,关于定时事件系统 的代码就这么多了,还是挺好理解的,接下来看看怎么在GamePlay 中应用吧。

Core 核心模块的代码就到此为止了。

GamePlay

本来想一片文章写完,但是现在写了近1w 字了,才刚说完Core 模块,如果一篇文章的话可能就3w字+了,所以还是先分一下章节吧,最后再合成一个大章。

下一篇将开始分析一些GamePlay 玩法,比如背包系统、对话系统…未完待续…

说说控