U3D动作游戏开发读书笔记--2.1一些通用的预备知识

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 自定义的插值公式

只需要给一个速率值相关的插值函数(帧数无关插值可以理解为没有一个时间限制,只需要提供一个速率值即可):

  1. Leap差值

  2. SmoothDamp差值函数差值

    Mathf.SmoothDamp 是 Unity 引擎中一个非常实用的数学函数,主要用于平滑地从一个值过渡到另一个目标值,并可以模拟真实世界中的物理运动效果,比如弹簧、惯性等。

​ 工作原理

Mathf.SmoothDamp 的核心原理是根据当前值和目标值之间的差异,动态调整速度,使运动看起来更加自然。 它会自动计算需要的加速度和减速度,创造出类似弹簧的平滑效果。

​ 特别要注意的是 currentVelocity 参数,它是一个引用参数,函数会在每次调用时更新它的值。这意味着你需 要在函数外部定义并维护这个变量,不能每次调用都创建新的变量。

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

与帧数相关的插值类型:

  1. Quicken类型

    复制代码
    t = t * t

    Quicken类型非常简单实用,而且不会造成太多开销。可以修改为t^n进行细调,其中,t是一个0~1之间的值.

  2. 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变量来检查是否被销毁,防止对已经销毁的单例进行重新创建。因为在单例销毁时不能保证外部是否完全没有调用情况,如果在销毁后外部有新的调用则重新生成一个单例,会影响掉我们期望此单例销毁的状态。

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