当前位置: 首页 > 工具软件 > 阿尔法 RPG > 使用案例 >

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

诸经略
2023-12-01

Creator Kit - RPG 简介

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

Core

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

全局单例

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

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游戏,需求是角色走到房子后面的时候,把房子变成半透明的。

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

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

  • 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
	/// <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 核心模块的代码就到此为止了。

GamePlay

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

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

 类似资料: