2.1 一些通用的预备知识:
2.1.1 使用协程分解复杂逻辑
试想一下如何实现一个简单的NPC人物行为,例如是村民。村民饿了会去吃饭,困倦了会去睡觉。上来上一个状态机?其实用不着这么复杂,可以使用协程来实现。
C#
namespace LearnBook.Chapter2
{
/// <summary>
/// 村民
/// 使用协程模拟村民的行为 不用使用复杂的状态机
/// 适合一些简单的 硬编码实现的 NPC行为
/// </summary>
public class Villager : MonoBehaviour
{
#region 状态常量
const float FATIGUE_DEFAULT_VALUE = 5F;
const float SATIATION_DEFAULT_VALUE = 5F;
private const float FATIGUE_MIN_VALUE = 0.2F;
const float SATIATION_MIN_VALUE = 0.2F;
#endregion
private float mSatiation; //饱食度
private float mFatigue; //疲劳度
private Coroutine mActionCoroutine; //NPC的行为协程
private void OnEnable()
{
//初始化状态:既不累也不
mSatiation = SATIATION_DEFAULT_VALUE;
mFatigue = FATIGUE_DEFAULT_VALUE;
//启动行为协程 模拟村民的行为
StartCoroutine(Tick());
}
/// <summary>
/// 模拟村民的行为的协程
/// </summary>
/// <returns></returns>
IEnumerator Tick()
{
while (true)
{
//更新饱食度和疲劳度 随着时间推移下降
mSatiation = Mathf.Max(0,mSatiation - Time.deltaTime);
mFatigue = Mathf.Max(0,mFatigue - Time.deltaTime);
if (mSatiation <= SATIATION_MIN_VALUE && mActionCoroutine == null)
{
mActionCoroutine = StartCoroutine(EatFood());
}
if (mFatigue <= FATIGUE_MIN_VALUE )
{
mActionCoroutine = StartCoroutine(Sleep());
}
//暂停下一帧 执行循环
yield return null;
}
}
/// <summary>
/// 模拟村民吃食物的行为
/// </summary>
/// <returns></returns>
IEnumerator EatFood()
{
mSatiation = SATIATION_DEFAULT_VALUE;
mActionCoroutine = null;
yield return null;
}
/// <summary>
/// 模拟村民睡觉的行为
/// </summary>
/// <returns></returns>
IEnumerator Sleep()
{
StopCoroutine(mActionCoroutine);
mFatigue = FATIGUE_DEFAULT_VALUE;
mActionCoroutine = null;
yield return null;
}
}
}
首先,设置一些常量数值:

然后再开始时候开启一个协程,协程内容每一帧执行一次,消耗精力数值和饱腹度,当消耗到最小的数值时便会触发执行对应的状态;

对应的状态也十分简单:在此帧率给自己状态进行充值。

这里睡觉的优先级高,假如正在吃饭 发现也需要睡觉 则停止吃饭转而睡觉。
实际上一些非常重要的角色或者关卡逻辑,如触发剧情走向的村民,结合协程进行硬编码处理非常高效。但是作为硬编码,可以理解为写死的逻辑,仅仅是方便对一些逻辑简化处理。
2.1.2 自定义的插值公式
只需要给一个速率值相关的插值函数(帧数无关插值可以理解为没有一个时间限制,只需要提供一个速率值即可):
-
Leap差值
-
SmoothDamp差值函数差值
Mathf.SmoothDamp
是 Unity 引擎中一个非常实用的数学函数,主要用于平滑地从一个值过渡到另一个目标值,并可以模拟真实世界中的物理运动效果,比如弹簧、惯性等。

工作原理
Mathf.SmoothDamp
的核心原理是根据当前值和目标值之间的差异,动态调整速度,使运动看起来更加自然。 它会自动计算需要的加速度和减速度,创造出类似弹簧的平滑效果。
特别要注意的是 currentVelocity
参数,它是一个引用参数,函数会在每次调用时更新它的值。这意味着你需 要在函数外部定义并维护这个变量,不能每次调用都创建新的变量。

书中所说的两种差值的效果:

与帧数相关的插值类型:
-
Quicken类型
t = t * t
Quicken类型非常简单实用,而且不会造成太多开销。可以修改为t^n进行细调,其中,t是一个0~1之间的值.
-
EaseInOut插值类型
t = (t - 1f) * (t - 1f) * (t - 1f) + 1f; t = t * t;
书中展示的插值效果:

2.1.3消息模块的设计
来实现一个简单的消息模块,功能支持订阅、取消订阅,消息的分发和缓存分发。
C#
namespace LearnBook.Chapter2
{
/// <summary>
/// 消息管理类 简单实现
/// 支持消息订阅 缓存分发消息
/// </summary>
public class MessageManager
{
static MessageManager mInstance;
public static MessageManager Instance
{
get
{
return mInstance ?? (mInstance = new MessageManager());
}
}
/// <summary>
/// 消息字典 存储消息 和 订阅者(回调函数:无返回值 有参数的方法)
/// </summary>
private Dictionary<string,Action<object[]>> mMessageDic = new Dictionary<string, Action<object[]>>(32);
/// <summary>
/// 缓存消息 存储消息 和 参数
/// </summary>
private Dictionary<string,object[]> mDispatchCacheDic = new Dictionary<string, object[]>(32);
private MessageManager()
{ }
/// <summary>
/// 订阅消息
/// </summary>
/// <param name="msg">消息名称</param>
/// <param name="action">回调函数</param>
public void Subscribe(string msg,Action<object[]> action)
{
if (mMessageDic.ContainsKey(msg))
{
mMessageDic[msg] += action;
}
else
{
mMessageDic.Add(msg, action);
}
}
/// <summary>
/// 取消订阅消息
/// </summary>
/// <param name="msg">消息名称</param>
public void Unsubscribe(string msg)
{
if (mMessageDic.ContainsKey(msg))
{
mMessageDic[msg] = null;
}
else
{
Debug.LogError("未订阅该消息");
}
}
/// <summary>
/// 分发消息
/// </summary>
/// <param name="msg">消息名称</param>
/// <param name="args">参数</param>
/// <param name="addToCache">是否添加到缓存</param>
public void Dispatch(string msg, object[] args = null,bool addToCache = false)
{
if (addToCache)
{
mDispatchCacheDic[msg] = args;
}
else
{
Action<object[]> action;
if(mMessageDic.TryGetValue(msg,out action))
{
action?.Invoke(args);
}
else
{
Debug.LogError("未订阅该消息");
}
}
}
/// <summary>
/// 处理缓存消息
/// </summary>
/// <param name="msg">消息名称</param>
public void ProcessDispatchCache(string msg)
{
object[] value = null;
if(mDispatchCacheDic.TryGetValue(msg,out value))
{
Dispatch(msg,value);
}
}
}
}
要点:
-
作为一个功能类型的管理脚本,自然设置为单例。
-
脚本中有两个字典,分别存储消息订阅方法引用(委托:回调函数容器)和存储消息缓存参数。
-
支持延迟分发(提前缓存调用函数的参数。
支持延迟分发是为了处理一些时序上的情景,假设玩家在游戏中获得新装备后,系统则会发送消息通知背包面板去显示第二个页签上的红点提示,但此时背包面板尚未创建,当玩家打开背包时消息早就发送过了。而延迟消息可以先把消息推送到缓存中,由需要拉取延迟消息的类自行调用拉取函数即可。
2.1.4模块间的管理与协调
简单的实现一个MonoBehaviour单例。
MonoBehaviour单例会在运行时创建一个Game-Object对象并置于DontDestroyOnLoad场景中,另外MonoBehaviour单例需注意销毁问题。
C#
amespace LearnBook.Chapter2
{
/// <summary>
/// 简单实现Mono单例
/// </summary>
public class MonoBehaviourSingleton : MonoBehaviour
{
private static bool mIsDestroying;
private static MonoBehaviourSingleton mInstance;
public static MonoBehaviourSingleton Instance
{
get
{
if (mIsDestroying)
{
return null;
}
if (mInstance == null)
{
mInstance = new GameObject("[MonoBehaviourSingleton]")
.AddComponent<MonoBehaviourSingleton>();
DontDestroyOnLoad(mInstance.gameObject);
}
return mInstance;
}
}
private void OnDestroy()
{
mIsDestroying = true;
}
}
}
要点:使用mIsDestroying
变量来检查是否被销毁,防止对已经销毁的单例进行重新创建。因为在单例销毁时不能保证外部是否完全没有调用情况,如果在销毁后外部有新的调用则重新生成一个单例,会影响掉我们期望此单例销毁的状态。

对于脚本之间有明确的依赖关系时,我们可以手动的更改脚本的编译优先级。
