使用协程分解复杂逻辑

在Unity中,协程可以处理一些异步任务。例如,假设某一个角色有“饱食度”和“困倦值”两个属性,这两个属性会不断衰减,当角色饿了会自己寻找食物,困了时会去睡觉。

不用状态机,借助协程解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
using System.Collections;
using UnityEngine;

namespace ACTBook
{
public class VillagerFSM : MonoBehaviour
{
public const float FATIGUE_DEFAULT_VALUE = 12f;//疲倦默认值
public const float SATIATION_DEFAULT_VALUE = 5f;//饱食度默认值

public const float FATIGUE_MIN_VALUE = 0.3f;//疲倦最小值
public const float SATIATION_MIN_VALUE = 0.2f;//饱食度最小值

[SerializeField]
Villager villager = null;

Coroutine mActionCoroutine;//当前动作协程
Coroutine mImportantActionCoroutine;//当前重要动作协程


void OnEnable()
{
villager.Satiation = SATIATION_DEFAULT_VALUE;
villager.Fatigue = FATIGUE_DEFAULT_VALUE;
//数值初始化

StartCoroutine(Tick());
}

IEnumerator Tick()//状态刷新循环
{
while (true)
{
villager.Satiation = Mathf.Max(0, villager.Satiation - Time.deltaTime);
villager.Fatigue = Mathf.Max(0, villager.Fatigue - Time.deltaTime);

if (villager.Satiation <= SATIATION_MIN_VALUE && mActionCoroutine == null)
{
mActionCoroutine = StartCoroutine(EatFood());
}

if (villager.Fatigue <= FATIGUE_MIN_VALUE && mImportantActionCoroutine == null)
{
if (mActionCoroutine != null)
{
StopCoroutine(mActionCoroutine);
mActionCoroutine = null;
}

mImportantActionCoroutine = StartCoroutine(Sleep());
mActionCoroutine = mImportantActionCoroutine;
}

yield return null;
}
}

IEnumerator EatFood()//处理吃食物具体动作
{
Debug.Log("开始寻找食物!");

yield return villager.Eat(() =>
{
Debug.Log("恢复饱食度!");
villager.Satiation = SATIATION_DEFAULT_VALUE;
});

mActionCoroutine = null;
}

IEnumerator Sleep()//处理睡觉具体动作
{
Debug.Log("开始睡觉!");

yield return villager.Sleep(() =>
{
Debug.Log("恢复精力!");
villager.Fatigue = FATIGUE_DEFAULT_VALUE;
});

mActionCoroutine = null;
mImportantActionCoroutine = null;
}
}
}


一些非常重要的角色或关卡逻辑,如触发剧情的村民,结合协程进行Hardcode(逻辑写死)处理非常高效。通常在Unity开发中这些根据剧情需要“写死”的角色逻辑的类命名也很简单,如NPC1001.cs、NPC1002.cs等。这些脚本不允许存在复用与依赖

关于协程的执行顺序参考协程的嵌套和调用模式,协程代码块内部的代码是按顺序依次执行的,而在外部调用协程的代码块是一次性执行的。要注意理解这种类似多线程代码的执行顺序。

在本案例的代码中还附带了villager自动寻路的业务逻辑,在这里贴出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
using System;
using System.Collections;
using UnityEngine;

namespace ACTBook
{
//该脚本定义了村民的基础字段和逻辑
public class Villager : MonoBehaviour
{
const float DOT_CHECK_ANGLE = 45f;

const int MOVE_ERROR_MAX = 20000;
const float MOVE_IN_RANGE_EPS = 0.001f;

[SerializeField]
SpriteRenderer spriteRenderer = null;
[SerializeField]
Sprite sideSprite = null;
[SerializeField]
Sprite upSprite = null;
[SerializeField]
Sprite downSprite = null;
[SerializeField]
float moveSpeed = 0.5f;

//在场景内放置几个空对象作为路径点,放在下面的Transform列表里
[SerializeField]
Transform[] toEatPathArray = null;
[SerializeField]
Transform[] toSleepPathArray = null;

public float Satiation { get; set; }
public float Fatigue { get; set; }


//协程拥有多个yield return没问题,从上到下依次执行,这个IEnumerator相当于async,标记这个函数为协程函数
public IEnumerator Eat(Action onAte)
{
//使用Array.ConvertAll来将Transform数组转化为Vector3数组
yield return MoveTo(Array.ConvertAll(toEatPathArray, m => m.position), moveSpeed);
yield return new WaitForSeconds(1f);
onAte?.Invoke();
var pointsReverse = Array.ConvertAll(toEatPathArray, m => m.position);
Array.Reverse(pointsReverse);
yield return MoveTo(pointsReverse, moveSpeed);
}

public IEnumerator Sleep(Action onSlept)
{
yield return MoveTo(Array.ConvertAll(toSleepPathArray, m => m.position), moveSpeed);
yield return new WaitForSeconds(1f);
onSlept?.Invoke();
var pointsReverse = Array.ConvertAll(toSleepPathArray, m => m.position);
Array.Reverse(pointsReverse);
yield return MoveTo(pointsReverse, moveSpeed);
}

IEnumerator MoveTo(Vector3[] pathPointArray, float speed)
{
for (int i = 0; i < pathPointArray.Length; i++)
{
var item = pathPointArray[i];

var pointReferenceDir = Vector3.forward;
if (i > 0)
pointReferenceDir = pathPointArray[i] - pathPointArray[i - 1];
else
pointReferenceDir = pathPointArray[i] - transform.position;

var dot = 1f;
var isInRadius = false;
var errorCount = 0;

var moveDirection = (item - transform.position).normalized;

if (moveDirection.normalized != Vector3.zero)
ChangeDirectionSprite(moveDirection);

//用do...while循环来控制角色移动,当角色到达指定路径点后,跳出此循环并执行for循环来确定下一个路径点的位置
do
{
if (errorCount > MOVE_ERROR_MAX)
{
//如果角色长时间无法到达指定路径点,就抛出异常
Debug.LogError("Path Move Looping Error!");
break;
}
errorCount++;

transform.position += moveDirection * Time.deltaTime * speed;

//每一帧都要计算角色当前位置和目标路径点的单位向量,防止移动中出现特殊情况让角色偏离
var referenceDirection = (item - transform.position).normalized;
//计算当前位移单位向量referenceDirection和路径点单位向量pointReferenceDir的点积(|a||b|cosθ),同向则dot > 0
dot = Vector3.Dot(pointReferenceDir.normalized, referenceDirection);
//计算当前角色和路径点之间的距离
isInRadius = Vector3.Distance(item, transform.position) <= MOVE_IN_RANGE_EPS;

yield return null;

} while (dot > 0 && !isInRadius);
}
}

//这个方法根据角色位移向量和上下左右四个方向向量的夹角大小关系来调整角色的sprite贴图
void ChangeDirectionSprite(Vector3 direction)
{
if (DotCheck(direction, new Vector3(0f, 1f, 0f)))
{
spriteRenderer.sprite = upSprite;
spriteRenderer.transform.localScale = new Vector3(1f, 1f, 1f);
}

else if (DotCheck(direction, new Vector3(0f, -1f, 0f)))
{
spriteRenderer.sprite = downSprite;
spriteRenderer.transform.localScale = new Vector3(1f, 1f, 1f);
}

else if (DotCheck(direction, new Vector3(1f, 0f, 0f)))
{
spriteRenderer.sprite = sideSprite;
spriteRenderer.transform.localScale = new Vector3(1f, 1f, 1f);
}

else if (DotCheck(direction, new Vector3(-1f, 0f, 0f)))
{
spriteRenderer.sprite = sideSprite;
spriteRenderer.transform.localScale = new Vector3(-1f, 1f, 1f);
}
}

bool DotCheck(Vector3 srcDir, Vector3 compareDir)
{
//计算位移单位向量和正向单位向量的点积,因为是单位向量,所以dot的值域为[-1,1]
var dot = Vector3.Dot(srcDir, compareDir);
//使用反余弦函数将dot的值转化为对应的弧度值,acos的值域为[0,Π]
var acos = Mathf.Acos(dot);
//将弧度值转化为角度值
var deg = acos * Mathf.Rad2Deg;
//角度值小于45度的方向是sprite应该转的方向
return deg < DOT_CHECK_ANGLE;
}
}
}

自定义插值公式

很多时候使用DOTween等一些插值插件来处理相关需求,但更多时候需要更灵活的插值处理,如发射导弹,释放火球等。

插值按数值类型分为整型和浮点型,按照应用分帧数相关类型和帧数无关类型。

Unity中的Lerp型函数

官方文档中的Lerp

Lerp的本质是返回量个值之间的线性插值:以Vector3.Lerp为例

Unity - Scripting API: Vector3.Lerp (unity3d.com)

public static Vector3 Lerp(Vector3 a, Vector3 b, float t);

在两点之间进行线性插值。 通过插值 t 在点 a 和 b 之间进行插值。参数 t 被限制在 [0, 1] 范围内。这最常用于沿着两个端点之间的一条线找到一个点(例如,在这些点之间逐渐移动一个对象)。

返回的值等于 a + (b - a) * t(也可以写成 a * (1-t) + b*t)。

当 t = 0 时,Vector3.Lerp(a, b, t) 返回 a。

当 t = 1 时,Vector3.Lerp(a, b, t) 返回 b。

当 t = 0.5 时,Vector3.Lerp(a, b, t) 返回 a 和 b 之间的中间点。

官方案例1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using UnityEngine;

public class VectorLerpTest : MonoBehaviour
{
public int interpolationFramesCount = 45; // Number of frames to completely interpolate between the 2 positions
int elapsedFrames = 0;

void Update()
{
float interpolationRatio = (float)elapsedFrames / interpolationFramesCount;

Vector3 interpolatedPosition = Vector3.Lerp(Vector3.up, Vector3.forward, interpolationRatio);

elapsedFrames = (elapsedFrames + 1) % (interpolationFramesCount + 1); // reset elapsedFrames to zero after it reached (interpolationFramesCount + 1),一定要+1,否则最后一帧达不到

Debug.DrawLine(Vector3.zero, Vector3.up, Color.green);
Debug.DrawLine(Vector3.zero, Vector3.forward, Color.blue);
Debug.DrawLine(Vector3.zero, interpolatedPosition, Color.yellow);
}
}

黄色的线在每帧滑动

官方案例2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using UnityEngine;

public class VectorLerpTest : MonoBehaviour
{
// Transforms to act as start and end markers for the journey.
public Transform startMarker;
public Transform endMarker;

// Movement speed in units per second.
public float speed = 1.0F;

// Time when the movement started.
private float startTime;

// Total distance between the markers.
private float journeyLength;

void Start()
{
// Keep a note of the time the movement started.
startTime = Time.time;

// Calculate the journey length.
journeyLength = Vector3.Distance(startMarker.position, endMarker.position);
}

// Move to the target end position.
void Update()
{
// Distance moved equals elapsed time times speed..
float distCovered = (Time.time - startTime) * speed;

// Fraction of journey completed equals current distance divided by total distance.
float fractionOfJourney = distCovered / journeyLength;

// Set our position as a fraction of the distance between the markers.
transform.position = Vector3.Lerp(startMarker.position, endMarker.position, fractionOfJourney);
}
}

方体从开始移动到结束并停下

Unity - Scripting API: Vector3.Slerp (unity3d.com)

Unity - Scripting API: Vector3.LerpUnclamped (unity3d.com)

Unity - Scripting API: Mathf.SmoothDampAngle (unity3d.com)

Unity中的SmoothDamp型函数

SmoothDamp函数

SmoothDamp是一种缓动型插值:以Vector3.SmoothDamp为例

public static Vector3 SmoothDamp(Vector3 current, Vector3 target, ref Vector3 currentVelocity, float smoothTime, float maxSpeed = Mathf.Infinity, float deltaTime = Time.deltaTime);

smoothTime: 大约需要达到目标所需的时间。较小的值将更快地达到目标。

maxSpeed: (可选)允许您限制最大速度。

deltatime: 自上次调用此函数以来的时间。默认情况下是Time.deltaTime。

随着时间的推移逐渐将向量更改为期望的目标。

向量被一些类似弹簧阻尼器的函数平滑,它永远不会过冲。最常见的用途是平滑跟随相机。

官方案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using UnityEngine;

public class SmoothDampTest : MonoBehaviour
{
public Transform target;
public float smoothtime = 0.3f;
private Vector3 velocity = Vector3.zero;
// Update is called once per frame
void Update()
{
Vector3 targetPosition = target.TransformPoint(new Vector3(0, 5, -10));
transform.position = Vector3.SmoothDamp(transform.position, targetPosition, ref velocity, smoothtime);
}
}

target.TransformPoint(new Vector3(0, 5, -10));指的是无论target的本地坐标在哪里,返回的targetPosition的值一直在相对于target世界坐标(0,5,-10)的位置

运行时移动target对象,可以发现物体在缓动跟随target

缓动

Quicken类型

这是书中提到的一种插值函数,是将Lerp函数的第三个参数传入时间的平方实现

Quicken是一种帧数相关的插值类型

1
t = t * t;

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

帧数相关的Quicken插值

这种插值也可以运用在非物体运动中。例如,PostProcessingStack插件的Bloom后处理效果,实现了超出颜色范围的平滑过渡处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using UnityEngine;

namespace ACTBook
{
public class Tween3 : MonoBehaviour
{
const float TIME = 3f;
const float DISTANCE = 3f;
const float SMOOTH_TIME = 0.2f;

Vector3 mVelocity;


void Update()
{
var t = Mathf.Repeat(Time.time, TIME) / TIME;
var quicken_t = t * t;
transform.localPosition = Vector3.Lerp(new Vector3(0f, 0f, 0f), new Vector3(DISTANCE, 0f, 0f), quicken_t);
}
}
}

EaseInOut类型

下面再介绍一种较为常用的EaseInOut插值类型:

1
2
3
t = (t - 1f) * (t - 1f) * (t - 1f) + 1f

t = t * t

第一行的逻辑表现更类似于SmoothDamp那种较为平滑的运动,第二行加入EaseIn插值使其达到淡入淡出的插值效果。

帧数相关额EaseInOut插值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using UnityEngine;

namespace ACTBook
{
public class Tween4 : MonoBehaviour
{
const float TIME = 3f;
const float DISTANCE = 3f;


void Update()
{
var t = Mathf.Repeat(Time.time, TIME) / TIME;
t = (t - 1f) * (t - 1f) * (t - 1f) + 1f;
t = t * t;
transform.localPosition = Vector3.Lerp(new Vector3(0f, 0f, 0f), new Vector3(DISTANCE, 0f, 0f), t);
}
}
}

消息模块设计(不依赖Unity)

实现一个简单的消息管理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;

public class MessageManager
{
static MessageManager m_Instance;
public static MessageManager Instance => m_Instance ??= new MessageManager(); //单例对象

Dictionary<string, Action<object[]>> m_MessageDict = new Dictionary<string, Action<object[]>>(32);//容量32

//分发消息缓存字典,主要应对消息还没注册但Dispatch已经调用的情况
Dictionary<string, object[]> m_DispatchCacheDict = new Dictionary<string, object[]>(16);
private MessageManager() { }

//订阅消息
public void Subscribe(string message,Action<object[]> action)
{
Action<object[]> value = null;
//已有消息则追加绑定
if(m_MessageDict.TryGetValue(message,out value))
{
value += action;
m_MessageDict[message] = value;
}
else
{
m_MessageDict.Add(message, action);
}
}
//取消消息订阅
public void Unsubscribe(string message)
{
m_MessageDict.Remove(message);
}
//分发消息
public void Dispatch(string message,object[] args = null,bool addToCache = false)
{
if (addToCache)//缓存针对手动拉取
{
m_DispatchCacheDict[message] = args;
}
else //不需要缓存的则触发
{
Action<object[]> value = null;
if (m_MessageDict.TryGetValue(message, out value))
value(args);
}
}
//处理分发消息缓存
public void ProcessDispatchCache(string message)
{
object[] value = null;
if(m_DispatchCacheDict.TryGetValue(message,out value))
{
Dispatch(message, value);
m_DispatchCacheDict.Remove(message);
}
}
}

以上是一个简单的消息管理器的实现,使用单例进行调用。除了订阅(Subscribe)、取消订阅(Unsubscribe),还需要处理延迟分发(Dispatch)的情况。假设玩家在游戏中获得新装备,系统会发送消息通知背包面板去显示第二个页签上的红点提示,但此时背包面板尚未创建怎么办,使用延迟消息先把消息推送到缓存中,需要时自行调用即可。

模块间的管理与协调

首先来说一下模块的单例,单例一般可以分为MonoBehaviour单例和常规单例,MonoBehaviour单例会在运行时创建一个GameObject对象并置于DontDestroyOnLoad场景中,另外,基于MonoBehaviour的单例需要注意销毁的问题。

下面来看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MonoBehaviourSingleton : MonoBehaviour
{
static bool m_IsDestroying;
static MonoBehaviourSingleton m_Instance;
public static MonoBehaviourSingleton Instance
{
get
{
if (m_IsDestroying) return null;
if (!m_Instance)
{
m_Instance = new GameObject("#MonoBehaviourSingleton#").AddComponent<MonoBehaviourSingleton>();
DontDestroyOnLoad(m_Instance.gameObject);
}
return m_Instance;
}
}
private void OnDestroy()
{
m_IsDestroying = true;
}
}

我们在其中加入m_IsDestroying变量,防止对已经销毁的单例进行重复创建。(所有静态变量会一直记着)

在模块初始化时还需要考虑其相互依赖关系,可以直接在Project Settings里面修改Script Execution Order

Script Execution Order