流程模块的作用

流程在实现上其实是对有限状态机的一个封装,如果未读本系列文章中的有限状态机解析篇,建议可以先看完有限状态机的解析再看本文。

那么流程是解决什么问题呢?我们来看看GF官方文档的定义:

贯穿游戏运行时整个生命周期的有限状态机。通过流程,将不同的游戏状态进行解耦将是一个非常好的习惯。对于网络游戏,你可能需要如检查资源流程、更新资源流程、检查服务器列表流程、选择服务器流程、登录服务器流程、创建角色流程等流程,而对于单机游戏,你可能需要在游戏选择菜单流程和游戏实际玩法流程之间做切换。如果想增加流程,只要派生自 ProcedureBase 类并实现自己的流程类即可使用。

实际上就是用有限状态机把游戏整体状态管理了起来,我们应该让游戏在生命周期中的任何一刻,都属于某个流程中,且同时只会处于一个流程状态中。虽然实现简单,但起到了很好的逻辑划分作用,也很方便后期调整各流程的顺序,甚至可以构建一颗流程树,根据不同环境走不同的流程分支。

笔者曾经经历过这样的情景:项目原本是进入游戏后先走更新流程,再登录的,后来渠道方要求要把登录步骤放在前面,登录后再走版本检测、更新流程,由于那个项目对启动流程管理并没有那么清晰,导致我们最终不得不重构了游戏启动流程的代码。但如果严格按照状态来划分每一个流程,那我们调整流程顺序将会和调整Animator连线一样简单(前提是调整的两个流程是没有依赖顺序的,例如在更新资源前,必须走完版本检测流程,这种是有依赖顺序的)。

另外要注意,一般地说,一个游戏拥有的流程数量是非常有限的,如果规划出数十个流程出来,很可能是对流程的理解有所偏差。例如一个塔防游戏有数十个关卡,每个关卡的内容都不一样,但关卡中的地图,炮塔,敌人生成等,其实都是数据驱动的,而他们的逻辑其实是一样的,只是数据不同造成表现不同,所以无论是哪个关卡,他们都应该属于同一个流程。

流程的实现

结构

流程基类

ProcedureBase类为所有流程的基类,它是一个抽象类,继承自FsmState,(定义在FSM模块中)泛型参数T为IProcedureManager,他具有FsmState的所有功能,虽然ProcedureBase重写了FsmState的生命周期方法,但并没有添加额外的逻辑。值得注意的是,ProcedureBase已经限定了持有者为IProcedureManager类型,也就是限定了ProcedureManager为流程持有者,ProcedureBase的子类不能改变这一限制。

流程管理类

简单地说ProcedureManager内部就是用FsmManager创建了一个专门管理游戏流程的状态机,并启动流程。

  • 字段m_FsmManager为有限状态机管理器,会在Initialize方法初始化时作为参数传入,m_ProcedureFsm为管理流程用的有限状态机。
  • 方法Initialize会取得FsmManager实例和包括所有流程(继承ProcedureBase的对象)的列表,并用FsmManager创建出一个状态机实例储存于m_ProcedureFsm中。
  • 与Fsm模块类似,流程模块提供HasProcedure、GetProcedure接口来查询和获取指定流程对象,CurrentProcedure获得当前处于的流程,CurrentProcedureTime获取当前流程持续时间。
  • StartProcedure方法,令状态机从指定流程启动,这里是游戏框架正式启动游戏的关键入口

流程组件

既然流程管理器里的StartProcedure方法是框架正式启动游戏的关键入口,那么这个StartProcedure是哪里调用的呢?看下面ProcedureComponent的部分代码。

ProcedureComponent属于框架UGF部分

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
private IEnumerator Start()
{
ProcedureBase[] procedures = new ProcedureBase[m_AvailableProcedureTypeNames.Length];
for (int i = 0; i < m_AvailableProcedureTypeNames.Length; i++)
{
Type procedureType = Utility.Assembly.GetType(m_AvailableProcedureTypeNames[i]);
if (procedureType == null)
{
Log.Error("Can not find procedure type '{0}'.", m_AvailableProcedureTypeNames[i]);
yield break;
}

procedures[i] = (ProcedureBase)Activator.CreateInstance(procedureType);
if (procedures[i] == null)
{
Log.Error("Can not create procedure instance '{0}'.", m_AvailableProcedureTypeNames[i]);
yield break;
}

if (m_EntranceProcedureTypeName == m_AvailableProcedureTypeNames[i])
{
m_EntranceProcedure = procedures[i];
}
}

if (m_EntranceProcedure == null)
{
Log.Error("Entrance procedure is invalid.");
yield break;
}

m_ProcedureManager.Initialize(GameFrameworkEntry.GetModule<IFsmManager>(), procedures);

yield return new WaitForEndOfFrame();

m_ProcedureManager.StartProcedure(m_EntranceProcedure.GetType());
}

ProcedureComponent是一个Mono类,上面的Start方法会被Unity内部主动调用,调用后会根据m_AvailableProcedureTypeNames通过反射来创建流程对象,也就是我们只需要定义了流程的类就行,不需要写实例化流程类的逻辑,然后会调用ProcedureManager的Initialize方法,进行初始化,再以m_EntranceProcedure为起始状态,启动流程状态机。

可视化配置流程

上文流程组件中提到,既然是通过m_AvailableProcedureTypeNames来创建实例,并以m_EntranceProcedure为起始状态,启动流程状态机,那么这两个变量是怎么来的呢。如上图所示,我们直接通过流程组件的Inspector来配置,GF会通过反射获取所有继承ProcedureBase的子类,并展示在此面板,我们只需要勾选需要流程即可把它加入到m_AvailableProcedureTypeNames中,而面板上的Entrance Procedure则代表了m_EntranceProcedure,这里我们选择了StarForce.ProcedureLaunch作为起始状态,那么ProcedureLaunch类中的OnEnter方法中的逻辑,就是我们游戏启动后最先执行的游戏业务逻辑。

示例

本模块示例直接引用GF的官方Demo中的前两个流程的代码,个人认为非常有参考价值,若对流程仍然有疑问,相信把官方Demo的流程都看一遍就明白了~

启动流程

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
public class ProcedureLaunch : ProcedureBase
{
public override bool UseNativeDialog
{
get
{
return true;
}
}

protected override void OnEnter(ProcedureOwner procedureOwner)
{
base.OnEnter(procedureOwner);

// 构建信息:发布版本时,把一些数据以 Json 的格式写入 Assets/GameMain/Configs/BuildInfo.txt,供游戏逻辑读取
GameEntry.BuiltinData.InitBuildInfo();

// 语言配置:设置当前使用的语言,如果不设置,则默认使用操作系统语言
InitLanguageSettings();

// 变体配置:根据使用的语言,通知底层加载对应的资源变体
InitCurrentVariant();

// 声音配置:根据用户配置数据,设置即将使用的声音选项
InitSoundSettings();

// 默认字典:加载默认字典文件 Assets/GameMain/Configs/DefaultDictionary.xml
// 此字典文件记录了资源更新前使用的各种语言的字符串,会随 App 一起发布,故不可更新
GameEntry.BuiltinData.InitDefaultDictionary();
}

protected override void OnUpdate(ProcedureOwner procedureOwner, float elapseSeconds, float realElapseSeconds)
{
base.OnUpdate(procedureOwner, elapseSeconds, realElapseSeconds);

// 运行一帧即切换到 Splash 展示流程
ChangeState<ProcedureSplash>(procedureOwner);
}

private void InitLanguageSettings()
{
if (GameEntry.Base.EditorResourceMode && GameEntry.Base.EditorLanguage != Language.Unspecified)
{
// 编辑器资源模式直接使用 Inspector 上设置的语言
return;
}

Language language = GameEntry.Localization.Language;
if (GameEntry.Setting.HasSetting(Constant.Setting.Language))
{
try
{
string languageString = GameEntry.Setting.GetString(Constant.Setting.Language);
language = (Language)Enum.Parse(typeof(Language), languageString);
}
catch
{
}
}

if (language != Language.English
&& language != Language.ChineseSimplified
&& language != Language.ChineseTraditional
&& language != Language.Korean)
{
// 若是暂不支持的语言,则使用英语
language = Language.English;

GameEntry.Setting.SetString(Constant.Setting.Language, language.ToString());
GameEntry.Setting.Save();
}

GameEntry.Localization.Language = language;
Log.Info("Init language settings complete, current language is '{0}'.", language.ToString());
}

private void InitCurrentVariant()
{
if (GameEntry.Base.EditorResourceMode)
{
// 编辑器资源模式不使用 AssetBundle,也就没有变体了
return;
}

string currentVariant = null;
switch (GameEntry.Localization.Language)
{
case Language.English:
currentVariant = "en-us";
break;

case Language.ChineseSimplified:
currentVariant = "zh-cn";
break;

case Language.ChineseTraditional:
currentVariant = "zh-tw";
break;

case Language.Korean:
currentVariant = "ko-kr";
break;

default:
currentVariant = "zh-cn";
break;
}

GameEntry.Resource.SetCurrentVariant(currentVariant);
Log.Info("Init current variant complete.");
}

private void InitSoundSettings()
{
GameEntry.Sound.Mute("Music", GameEntry.Setting.GetBool(Constant.Setting.MusicMuted, false));
GameEntry.Sound.SetVolume("Music", GameEntry.Setting.GetFloat(Constant.Setting.MusicVolume, 0.3f));
GameEntry.Sound.Mute("Sound", GameEntry.Setting.GetBool(Constant.Setting.SoundMuted, false));
GameEntry.Sound.SetVolume("Sound", GameEntry.Setting.GetFloat(Constant.Setting.SoundVolume, 1f));
GameEntry.Sound.Mute("UISound", GameEntry.Setting.GetBool(Constant.Setting.UISoundMuted, false));
GameEntry.Sound.SetVolume("UISound", GameEntry.Setting.GetFloat(Constant.Setting.UISoundVolume, 1f));
Log.Info("Init sound settings complete.");
}
}

闪屏流程

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
public class ProcedureSplash : ProcedureBase
{
public override bool UseNativeDialog
{
get
{
return true;
}
}

protected override void OnUpdate(ProcedureOwner procedureOwner, float elapseSeconds, float realElapseSeconds)
{
base.OnUpdate(procedureOwner, elapseSeconds, realElapseSeconds);

// TODO: 这里可以播放一个 Splash 动画
// ...

if (GameEntry.Base.EditorResourceMode)
{
// 编辑器模式
Log.Info("Editor resource mode detected.");
ChangeState<ProcedurePreload>(procedureOwner);
}
else if (GameEntry.Resource.ResourceMode == ResourceMode.Package)
{
// 单机模式
Log.Info("Package resource mode detected.");
ChangeState<ProcedureInitResources>(procedureOwner);
}
else
{
// 可更新模式
Log.Info("Updatable resource mode detected.");
ChangeState<ProcedureCheckVersion>(procedureOwner);
}
}
}

Inspector面板

Procedure组件的Inspector面板在运行时会禁止配置操作,且最上面会多出一行信息显示当前正处于的流程。

思考

既然已经有状态机模块了,为什么还要另外封装一个流程模块?自己单独用一个状态机实例去管理不是一样效果吗

笔者的看法是,仅仅在功能上来看,是一样的,差别主要是以下:

  • 普通的状态机状态一般属于各自系统去管理,系统外部不会去访问他们,而流程则很可能需要在各个系统访问,以获取当前流程信息,所以需要为专门管理流程的状态机提供一个全局访问的接口。GF的做法则是把Procedure模块单独提出来与FSM同级,都属于全局访问的模块。
  • 流程需要继承自ProcedureBase,而ProcedureBase限定了持有者为ProcedureManager,把流程与普通状态进一步划分开来。
  • 基于GF在会在Hierarchy挂上各模块对应的组件以初始化模块,以及利用编辑器扩展实现可视化。在流程单独作为一个模块后,可以更方便地可视化配置和调试。

最后

GameFramework解析 系列目录:GameFramework解析:开篇

个人原创,未经授权,谢绝转载!