Unity手游开发札记——从Odin插件聊基于元数据的编辑器实现
Metadata is data that provides information about other data.最近一个多月的时间在全力做新项目的Demo,由于程序暂时还只有我一个人,所以从程序架构搭建到战斗逻辑实现,再到编辑器开发都是我自己的工作。之前有较长一段时间日常的工作内容已经集中在团队管理、图形渲染、性能优化等方面,在具体业务中做的内容比较少了,这段时间重拾游戏玩法的开发,虽然除了进度压力之外没有太多技术挑战,但也借这个机会回顾和反思之前的一些做法和代码,有不少收获。另外一个感受就是——如果不想那么多,埋头醉心于玩法的实现,每天写上大几百行代码,也可以获得最为简单而直接的成就感。当然,代码量并不是最为直接的成就感来源,如果可以用更少的代码实现更强大的功能,才是作为最会“偷懒”的程序员最为理想的工作方式。也恰恰是这个原因,才催生各种应对变化的设计模式,有了github这样的开源社区,以及开头提到的元编程(Meta Programming)这样的编程理念。
我们暂时放下对于编程理念的讨论,先从需求的根源来看一下它可能的一个应用环境——编辑器的开发。
1. 编辑器 vs Excel
当你需要策划编辑一份数据的时候,你问他——你是想用Excel填写还是需要我帮你做一个编辑器?不同的人可能有不同的回答,这取决于策划的经验、喜好,也会受到数据结构的复杂程度的影响。争论这两者的优劣没有什么意义,找到他们各自的适用场景才是关键所在。
Excel本身具有超级强大的功能,它的设计目的就是编辑二维数据,并在此之上构建了丰富的统计和分析功能,如果再加上vba的帮助,那简直无所不能。之前就认识一个策划,整个游戏的数值演算以及带有交互的基本Demo原型都在Excel中直接进行了实现。当然,它的缺点也比较明显,就像MySQL之于Mongo一样,Excel对于非结构化的数据支持比较麻烦,比如技能这样相对复杂的数据就要拆表等方式来描述,策划填写时需要跨越多张表格,不够直观,比较费时,也容易出错。
编辑器在应对非结构化数据时就相对容易一些,界面和操作方式可以根据需求直接定制,和游戏内具体对象的交互也比较方便,比如技能编辑器中常常需要的预览动作、特效等功能,编辑器更容易做到所见即所得,对于一些资源的填写也可以直接使用选择的方式,而不需要手动复制和修改,更加不容易出错。
在新的项目中,游戏的战斗逻辑选择放置在了Lua层,因此和战斗紧密结合的技能数据也只能选择放置在Lua端。最初的战斗Demo只设置了Lua Table的数据格式,然后通过直接编写这个Lua文件就可以更改技能的各种效果。最终让策划来进行技能编辑工作的时候一开始也想要不尝试下Excel的方式,但最后还是选择了为策划编写一个简单的技能编辑器,主要原因有这么几点:
目前的技能结构抽像的比较简单,貌似Excel勉强还可以支持,但是未来策划肯定会增加更多特殊的需求,这样就会导致表格列数膨胀得比较厉害;技能数据因为和技能释放、判定流程相关,如果出现非法值处理起来就比较麻烦,因此需要对数据做很严格的检查,Excel的方式可以在导表后处理里进行,但是策划只有在真正执行导表程序之后才能知道哪些地方有问题,错误排查不够直观;未来除了基本的数据之外,还需要攻受击特效、音效等和Unity本身相关的表现需要添加,正如前文所述,这种情况情况下Excel中填写资源路径比较麻烦,容易出错,而且导表几乎无法验证;在购买和试用了Odin插件之后,我发现Unity中做数据的编辑和验证变得异常简单和方便!
2. 基于元数据的编辑器框架
记忆中大约是在大三的时候,给我们上软件工程课程的老师在课堂上说他带的学生在编写可以写代码的代码,一脸神秘和骄傲。具体描述的应用场景已然流逝在了时间的长河中,但这所带给在当时还只会C语言和C++以及一些基本编程知识的我的那种惊讶和震撼却留在了我的记忆中。再到后来,虽然接触了http://ASP.net、还有WPF这些框架,但对于那些由xml或者别的格式来描述的界面信息最终是如何转变为一个可以响应逻辑事件的控件的,并没有非常清楚的认知,只是停留在使用的层面。直到后来做《无尽战区》项目,最初大量的编辑器都是使用引擎内部的UI来进行开发的,也就是和游戏UI是同一套东西,编写起来比较麻烦,老大说我们要做一套可以根据配置自动生成界面的元数据框架,这样可以大大提高比如技能编辑器这种需要大量界面工作的开发和迭代效率。
这是我第一次深入的去思考和理解通过配置来描述一个界面的方法,以及如何最终将配置转变为一个个的界面元素,从而组合成一个交互界面。以技能编辑器为例,整个框架所要包含的核心模块可以用下图这样一个结构来大致描述:
首先要有一些基础的界面通用控件,Button、Text、Slider等等,然后要提供自动化的界面布局功能。编写编辑器的程序需要编写的只是一份描述某个数据自身特性的元数据文件,在这里也就是描述技能的元数据信息,比如描述了技能一个技能包含哪些字段,这些字段分别是什么类型,按照什么样的方式让策划编辑,取值范围是多少等等。这些信息按照框架定义好的方式进行描述,元数据解析功能就可以解析这些配置,然后结合界面生成功能产出最终的技能编辑器界面,供策划编辑。最后导出的技能配置数据的格式,也会结合解析出来的元数据信息进行导出和检查。这个过程,就大致解释了元数据的基本概念——元数据提供了其他数据的格式信息。在这个例子中,技能元数据就定义了最终编辑出来的技能数据所包含的信息,以及要在编辑器中展示这些数据所需要的信息。
这套结构具体的信息不方便细说,但这里可以从Traits这个开源库来看下在Python中进行数据描述的方法和形式。
Traits为Python对象的属性增加了类型定义的功能,但除此之外还有其他的作用:
初始化:每个trait属性都定义有自己的缺省值,这个缺省值用来初始化属性验证;基于trait的属性都有明确的类型定义,只有满足定义的值才能赋值给属性委托;trait属性的值可以委托给其他对象的属性监听;trait属性的值的改变可以触发指定的函数的运行可视化;拥有trait属性的对象可以很方便地提供一个用户界面交互式地改变trait属性的值。
在官方的介绍中给了一个简单的例子描述了上面的几个核心功能:
from enthought.traits.api import Delegate, HasTraits, Instance, Int, Str
class Parent ( HasTraits ):
# 初始化: last_name被初始化为'Zhang'
last_name = Str( 'Zhang' )
class Child ( HasTraits ):
age = Int
# 验证: father属性的值必须是Parent类的实例
father = Instance( Parent )
# 委托: Child的实例的last_name属性委托给其father属性的last_name
last_name = Delegate( 'father' )
# 监听: 当age属性的值被修改时,下面的函数将被运行
def _age_changed ( self, old, new ):
print 'Age changed from %s to %s ' % ( old, new )这个简单的例子展示了Traits的基本用法,在Traits中,对于每一个trait属性都有一个与之对应的trait对象描述它。而元数据就是保存在trait对象中的额外的描述属性用的数据。这些元数据属性可以分为三类:
内部属性 : 这些属性是trait对象自带的,只读不能写;识别属性 : 这些属性是可以自由地设置的,它们可以改变trait的一些行为;任意属性 : 用户自己添加的属性,需要自己编写程序使用它们。
而基于这些属性,就可以描述一份数据的各项信息,于是编写出来的这份“代码”,也可以被称为元数据。
更加详细的信息有兴趣的读者可以参考Traits的文档描述,这里就不再赘述了。接下来我们核心来看看本次的主角——Odin插件。
3. Odin插件
Unity引擎对于自定义编辑器的支持已经比传统的游戏引擎要方便一个等级了,它基于Unity的反射机制,在界面框架内已经实现了非常方便的属性编辑功能,比如最为常见和常用的MonoBehavior的属性编辑和查看。相比于自研引擎要自己实现前文所描述的这套元数据编辑器框架,对于常规的配置需求Unity已经做得更好了。
然而,作为开发者来说还是有更加复杂的需求是Unity这套结构目前所不支持的,比如Dictionary的编辑,比如一些动态的显隐控制。好在Unity有强大的Asset Store,Odin这样的插件也就应运而生,虽然55美元的售价稍微有些贵,但我觉得它绝对物超所值!引用一段官方介绍来描述其功能:
Odin puts your Unity workflow on steroids, making it easy to build powerful and advanced user-friendly editors for you and your entire team. With an effortless integration that deploys perfectly into pre-existing workflows, Odin allows you to serialize anything and enjoy Unity with 80+ new inspector attributes, no boilerplate code and so much more!简答来说,它通过提供更多的新属性来方便我们编写强大的编辑器功能,并且提供了序列化模块。抛开序列化不说,我就举几个自己真正使用过的例子来描述它的一些好用功能。
3.1 字典编辑
字典的编辑这里直接给一个官方的例子:
public class DictionaryExamples : SerializedMonoBehaviour
{
[InfoBox("In order to serialize dictionaries, all we need to do is to
inherit our class from SerializedMonoBehaviour.")]
public Dictionary<int, Material> IntMaterialLookup;
public Dictionary<string, string> StringStringDictionary;
[DictionaryDrawerSettings(KeyLabel = &#34;Custom Key Name&#34;, ValueLabel =
&#34;Custom Value Label&#34;)]
public Dictionary<SomeEnum, MyCustomType> CustomLabels;
[DictionaryDrawerSettings(DisplayMode =
DictionaryDisplayOptions.ExpandedFoldout)]
public Dictionary<string, List<int>> StringListDictionary;
public Dictionary<SomeEnum, MyCustomType> EnumObjectLookup;
public struct MyCustomType
{
public int SomeMember;
public GameObject SomePrefab;
}
public enum SomeEnum
{
First, Second, Third, Fourth, AndSoOn
}
}
最后的编辑界面如下图所示:
这里有比较方便的添加和删除功能,对于复杂的数据结构,也可以采用Foldout的方式。同时这里也可以看到对于枚举类型和自定义结构的支持。
3.2 动态的下拉列表
为了减少使用者出错的概率,下拉列表是一个非常常见的需求,而其中的内容往往是动态变化的,Odin提供了ValueDropdown属性来应对这一需求,只需要定义一个相应的获取函数就可以了。
public int offetOnAtk = -1;
private IEnumerable<int> GetOffsetIDs()
{
List<int> oIds = new List<int>();
//处理逻辑
return oIds;
}
显示效果如下图所示:
3.3 错误提示信息
Odin也提供了丰富的信息提示功能,比如PropertyTooltip,是在鼠标在属性名称上悬停时显示的tips,LabelText是最为基础的显示名称,InfoBox可以定义单独的提示信息,而且可以给出显示条件,比如一个布尔值属性或者返回为布尔值的函数。
[LabelText(&#34;技能时长&#34;), PropertyTooltip(&#34;技能的持续时间,0表示动态技能时长&#34;),
MinValue(-1f)]
[InfoBox(&#34;必须有一个触发技能结束的位移才可以使用动态技能时长!&#34;,
InfoMessageType.Error, &#34;NoDynamicLength&#34;)]
public float length = 1.0f;
提示信息的显示效果如下图所示:
3.4 根据条件显示和隐藏
在编辑器中,某些属性的是隶属于其他属性的,比如定义一个形状,如果是个原型,则只需要半径就可以了,如果是个扇形,则还需要一个角度参数。通常的解决方法要么为这两种形状提供不同的编辑器功能,要不就把最大范围的属性都显示出来,让使用者随意填写。第一种方法有时候稍显复杂,第二种又会使编辑器使用者关注的信息膨胀,那么这时候就可以使用ShowIf或者EnableIf属性。
public int angle = 0;
这样就只有当shapeType是扇形的时候,才会显示扇形角度属性。
3.5 自动的TreeView
Odin提供的OdinMenuEditorWindow默认集成了一个TreeView列表放在左侧,为很多编辑器的开发提供了便利。比如官方提供的一个RPG类型游戏的编辑器Demo:
Odin插件的功能还有很多,这里就不一一列举,有兴趣的朋友可以去官网查看或者自己购买一份来学习和试验。总之,借助Odin的强大功能,我原本计划要3-5天才能完成的技能编辑器,只使用了1天时间就完成了核心框架。再加上我自己实现的一个简单的C#数据导出为Lua Table的功能,基本就满足了Demo期的核心需求。
4. 原理和简单扩展
如果你购买了Odin插件,是可以直接获取它的源码的,因此也就可以一探它具体的实现原理了。由于是收费插件的源码,这里就不做特别细致的探讨了,总体来说,Odin就是基于两个技术的结合来实现的:
属性(Attributes)反射(Reflection)
反射的部分比较好理解,比如ValueDropdown(&#34;GetOffsetIDs&#34;)这样的定义中,方法的名称使用一个字符串来进行描述,那么在最终执行的时候,肯定是要通过反射来获取具体的函数来执行,并获取返回值,这时候就要借助C#的反射机制才可以实现。而Odin的便利性则主要通过C#的属性来实现。
微软官方对于Attributes的定义如下:
Attributes provide a powerful method of associating metadata, or declarative information, with code (assemblies, types, methods, properties, and so forth). After an attribute is associated with a program entity, the attribute can be queried at run time by using a technique called reflection.你看,Attributes本身就是元数据的理念,它在绑定之后也是通过反射来查询的。属性具有如下的特点:
添加元数据到你的程序中,元数据在程序中是关于类型定义的信息。属性也可以自定义;属性可以被应用于整个程序集、模块,或者像类和属性(Properties)这样更小的程序单元;属性可以像方法和Properties一样接收参数;借助反射,你可以查询自己或者其他程序定义的元数据信息。
语言层面更加细节的原理不在本文的讨论范围内,我想借助我在技能编辑器实现时基于Attributes实现的一个功能来尝试描述一下Odin的基本原理。
在实现从C#数据导出Lua数据功能的时候,我想尝试优化导出后的文件大小以避免后续技能过于复杂时对于Lua虚拟机内存的影响。最为基本的一个优化就是如果策划填写的内容和默认值相同,就不需要导出这个数据,在Lua代码中通过a = a or default_value这种方式来获取值即可。如果要自己在导出函数中进行实现,或者通过一个属性名称白名单的数组来进行维护都会比较麻烦,Attributes是一个非常合适的功能。于是我设计了一个NotExportToLua的Attribute,它用来描述这个属性是否要导出到Lua中,基本实现如下:
public class NotExportToLuaDataAttribute : System.Attribute
{
object defaultValue = null;
public NotExportToLuaDataAttribute()
{
}
//如果和默认值相等则无需导出
public NotExportToLuaDataAttribute(Object defaultValue)
{
this.defaultValue = defaultValue;
}
public bool NeedNotExportToLua(object value)
{
if (defaultValue == null)
{
return false;
}
//这里的判断非常不严谨,只使用转换为字符串的方式来判断基础类型的对象是否相等。
return defaultValue.ToString() != value.ToString();
}
}
这个Attribute支持无参数和有参数两种形式,如果无参数则这个属性只会在C#中使用,无需导出到Lua数据中,而如果给予参数,在定义了默认值,导出时会检查当前值对象的值是否和属性值相同,如果相同则同样不导出。这里为了快速实现,使用了ToString的方式临时实现,并不是最正确的做法。
Type type = obj.GetType();
string indentStr = GetIndentation(indentLevel);
builder.Append(&#34;{\n&#34;);
bool first = true;
foreach (FieldInfo f in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
{
var value = f.GetValue(obj);
if (!IsNotExportToLua(f, value))
{
//Process export.
}
}
public static bool IsNotExportToLua(FieldInfo type, Object value)
{
NotExportToLuaDataAttribute attr = type.GetCustomAttribute<NotExportToLuaDataAttribute>();
if (attr != null)
{
if (!attr.NeedNotExportToLua(value))
{
return true;
}
}
object[] attrs = type.GetCustomAttributes(true);
for (int j = 0; j < attrs.Length; j++)
{
Type t = attrs.GetType();
if (t == typeof(System.ObsoleteAttribute))
{
return true;
}
}
return false;
}
在导出的逻辑中,首先借助反射函数GetFields获取一个对象所有的属性,然后通过GetCustomAttribute方法获取其身上对应类型的自定义Attribute,借助NotExportToLuaDataAttribute身上的NeedNotExportToLua来判断是否需要导出该属性。
这样,在你不需要导出一个C#中定义的属性的时候,只要为其添加就可以了,这也就非常灵活地实现了前面的需求。Odin插件对于Attribute的定义以及通过反射获取的数据更多,但其基本原理和我自己实现的这个NotExportToLuaData Attribute基本类似。
5. 总结
Meta Programming is about writing code that writes code.从元数据聊到元编程稍微有点刻意把这篇文章的立意拔高的意思,但它们两个之间的确有着相似的理念。用数据描述数据,用代码来生成代码,都是为了提高开发效率而“偷懒”的方法。从某个角度来说,处理元数据的代码就是在通过对于数据的描述来减少重复代码的编写,也可以说它是代替程序编写重复的代码。
对于元数据来说,像Lua的元表一样,她也可以有递归描述的能力,比如可以使用一份元数据来描述描述技能信息的元数据,就是元数据的元数据,通过它配合一个编辑器可以让策划自己定义一个技能中的数据有哪些,它们分别是什么样的类型或者要满足什么要的条件,这是更高层次的抽象。也许在未来,程序可以借助深度学习或者其他AI技术,开发一个自己写代码实现需求的AI程序,这个开发过程,似乎可以称之为Meta-Meta Programing……
恩,扯得有点远了,无论元数据也好,元编程也好,起码目前阶段的核心作用都是节省程序的开发时间,或者增强程序的功能。这篇文章还讲的比较浅显,从Odin插件的使用出发,稍微聊了一下元数据的基本思路和方法,无论你是否会使用到Odin插件,都希望这篇可以帮你开阔思路,从而节省一些开发时间,毕竟——“时间就是金钱,我的朋友。”
2019年7月8日晚于杭州家中
页:
[1]