Unity 官方的几个教程代码之一,适合入门学习。
实现了多个模块,本系列就逐步学习一下这个项目的源码。
核心模块,主要是实现一些框架层功能,这里主要实现了 全局单例、实体追踪、事件系统
根据类型注册全局单例,实现比较简单,直接看代码就懂了
namespace RPGM.Core
{
/// <summary>
/// A static class which maps a type to a single instance.
/// </summary>
/// <typeparam name="T"></typeparam>
static class InstanceRegister<T> where T : class, new()
{
public static T instance = new T();
}
}
如果想实现一个单例类,提供一个无参默认构造函数,之后使用InstanceRegister就能注册一个全局单例。后面的使用也用InstanceRegister<xx>.instance
来使用。
注意类后面的 where T : class, new()
这个语法,where T:
表示对 T
的限制,后面加上限制条件,class
表示T
必须是个类,不能是接口、结构体等,new()
表示T
必须实现无参构造函数,类似用法如下。
where T : struct // T must be a struct
where T : new() // T must have a default parameterless constructor
where T : IComparable // T must implement the IComparable interface
这个也比较短,先上代码。
namespace RPGM.Core
{
/// <summary>
/// Monobehavioids which inherit this class will be tracked in the static
/// Instances property.
/// </summary>
/// <typeparam name="T"></typeparam>
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游戏,需求是角色走到房子后面的时候,把房子变成半透明的。
实现思路也很简单,角色的触发器和房子的触发器 触发的时候,就设置房子的透明度。
明白了需求和实现方法,下面这两段代码就能看懂了。
[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;
}
}
/// <summary>
/// A system for batch animation of fading sprites.
/// </summary>
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
{
/// <summary>
/// An event allows execution of some logic to be deferred for a period of time.
/// </summary>
/// <typeparam name="Event"></typeparam>
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()
{
}
}
/// <summary>
/// Add functionality to the Event class to allow the observer / subscriber pattern.
/// </summary>
/// <typeparam name="T"></typeparam>
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.
/// </summary>
/// <typeparam name="T"></typeparam>
static public T GetModel<T>() where T : class, new()
{
return InstanceRegister<T>.instance;
}
/// <summary>
/// Set a simulation model instance for a class. Uses reflection
/// to preserve existing references to the model.
/// </summary>
/// <typeparam name="T"></typeparam>
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 接口
}
}
/// <summary>
/// Destroy the simulation model instance for a class.
/// </summary>
/// <typeparam name="T"></typeparam>
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()
方法,这个对象池就是存储的这些类的对象,避免频繁创建销毁对象。
/// <summary>
/// Create a new event of type T and return it, but do not schedule it. 只创建不调度,这个应该设置为private 吧
/// </summary>
/// <typeparam name="T"></typeparam>
/// <returns></returns>
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();
}
/// <summary>
/// Schedule an event for a future tick, and return it.
/// </summary>
/// <returns>The event.</returns>
/// <param name="tick">Tick.</param>
/// <typeparam name="T">The event type parameter.</typeparam>
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;
}
/// <summary>
/// Reschedule an existing event for a future tick, and return it.
/// </summary>
/// <returns>The event.</returns>
/// <param name="tick">Tick.</param>
/// <typeparam name="T">The event type parameter.</typeparam>
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
方法,主要区别是:第一个尝试使用对象池,第二个不使用对象池。
/// <summary>
/// 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.
/// </summary>
/// <returns></returns>
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($"<color=green>{ev.tick} {ev.GetType().Name}</color>");
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 核心模块的代码就到此为止了。
本来想一片文章写完,但是现在写了近1w 字了,才刚说完Core 模块,如果一篇文章的话可能就3w字+了,所以还是先分一下章节吧,最后再合成一个大章。
下一篇将开始分析一些GamePlay 玩法,比如背包系统、对话系统…未完待续…