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;   // 下标,第一个是0Instances.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();elsereturn 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 玩法,比如背包系统、对话系统…未完待续…

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

  1. 【Creator Kit - RPG 代码分析】(4)-游戏玩法-对话框、云朵系统、帧序列动画控制器、动画状态回调、音乐控制

    GamePlay 对话框 上一章说的输入控制器里,还有对对话框输入的控制,这小节就看看对话框的整体实现方式. 对话框输入控制 先看一下对话框输入控制,我对其有一点点修改. void DialogCon ...

  2. 【Creator Kit - RPG 代码分析】(2)-游戏玩法-背包系统

    GamePlay 这篇开始来讲这个教程代码实现的游戏玩法逻辑 背包系统 效果 先看这个背包的UI 效果图 基本单元item 每个物体都有一个基本单元 Item namespace RPGM.Gamep ...

  3. day02-抽象类,接口、代码块、final、单例、枚举

    java基础[抽象类,接口.代码块.final.单例.枚举] 第一章 抽象类 1.1 概述 1.1.1 抽象类引入 父类中的方法,被它的子类们重写,子类各自的实现都不尽相同.那么父类的方法声明和方法主 ...

  4. Cortex-M启动代码分析(以STM32F4为例)

    ARM Cortex-M系列MCU的启动代码(使用汇编语言编程则不需要)主要做3件事情: 1.初始化并正确放置异常/中断向量表: 2.分散加载: 3.初始化C语言运行环境(初始化堆栈以及C Libra ...

  5. Java基础学习系列--(二)【抽象类,接口、代码块、final、单例、枚举】

    第一章 抽象类 1.1 概述 1.1.1 抽象类引入 父类中的方法,被它的子类们重写,子类各自的实现都不尽相同.那么父类的方法声明和方法主体,只有声明还有意义,而方法主体则没有存在的意义了(因为子类对 ...

  6. Pixhawk代码分析-源码框架

    源码框架 pixhawk代码框架: pixhawk代码框架基础分析: 阅读下面内容时请结合源码阅读,便于理解. The basic structure of ArduPilot is broken u ...

  7. 团队项目代码分析(Android游戏:别踩白块儿)

    代码组成部分: 关键代码主要分为三大部分,如下图所示(用思维导图的形式展示): 代码调用关系 通过MainActivity调用其他类❤,具体见核心代码分析! 核心代码分析 public class P ...

  8. 海信变频空调故障代码分析与检修案例

    海信变频空调故障代码分析与检修案例 [例1] 海信KFR-2801GW/Bp型变频空调用遥控器不能开机   分析与检修:通过故障现象分析得知,说明遥控器或机内遥控接收头异常.检查遥控器的电池正常,经询 ...

  9. planner_wisdom(),fftw_wisdom_lookup(),fftw_measure_runtime(),init_test_array()函数代码分析

    代码分析以fftw2.15为例,原代码在fftw/planner.c中 planner_wisdom()函数是fftw为了运行效率提出的wisdom机制,主要思想是通过查找之前相似数据(结构.大小等相 ...

最新文章

  1. python paramiko模块中设置执行命令超时值
  2. php json与接口的使用,api接口与json
  3. 清华团队研发,首款国产电力电子仿真软件来啦~已捐赠哈工大、海工大、清华使用!...
  4. android sdk版本兼容,Android 版本兼容
  5. 导出excel 数据取一次合理还是分页取合理_一张报表模板替代数百张Excel表格,用它让报表工作更轻松...
  6. Java 文件下载,文件名乱码问题解决。
  7. python猜数字十次、猜对输出猜了多少次_python-猜数字小练习
  8. FASTQ 格式说明
  9. OPPO,ViVO手机锁屏下弹出来电界面
  10. Brotli压缩算法
  11. Mybatis入门(二)
  12. ffmpeg 分离视频音频流 缺失sei信息
  13. i 春秋CTF题目 百度杯 9月场 再见CMS Upload 复现
  14. 印能捷怎样安装在虚拟服务器,超详细Prinergy(印能捷)安装及设置教程
  15. EXCEL Comapre工具使用说明
  16. android自定义Glide图片加载(以更改Glide缓存路径和使用ARGB_8888的图片格式为例)
  17. 三维计算机辅助设计教程,三维计算机辅助设计教程-Pro ENGINEER.pdf
  18. 为什么知乎上很多人都反对创业?
  19. shell获取明天、上周、上个月时间
  20. 《测试之道》第三篇——吴钩霜雪明

热门文章

  1. MYSQL查询之count(1)与max (case when then else end)用法小结
  2. 无线用户搭建服务器,无线用户逃生功能典型配置案例
  3. 振憾 ajax与yahoo map加上美国各大赛事的widget
  4. 《前端》echarts排行榜,类目名字在柱子上方全部显示,前三名序号使用自定义图片背景--什么鬼待处理
  5. 为什么工业交换机需要CE认证
  6. SearchIndexer.
  7. 街景影像分析入门(一)街景影像采样点的生成
  8. android固件轻松,安卓轻松改工具-安卓固件修改软件-安卓轻松改工具下载 v1.0.2官方版-完美下载...
  9. 中国最牛的程序员在哪个省?
  10. ALTERA系列的FPGA通过RS232串口在线升级