Unity

DSL在项目中的应用:用DSL高效组织游戏情节

最近工作当中,任务部分的代码写的耦合度非常高并且也不好扩展,由我接手重构之后我自己编写了一个简单的DSL用于帮助策划配置情节,在这里简单记录一下吧。

由于开发时间非常紧,重构时间整个任务以及NPC对话系统只有短短三天而已,实际将DSL完成并且接入也仅仅花了一天的时间而已,也不需要自己过分编写语法解析之类的轮子。

当然如果希望精益求精的话当然可以自己开发一套完整的语言,但是够用就行了,不是吗~

为什么要剧情脚本

相对于游戏逻辑,剧情逻辑更像是传媒学生在剪辑片子,逻辑更加线性,而不像其他逻辑呈现树状结构。

行为树插件 Behavior Designer

同样的,我们试着思考一下,如果用节点编辑器(类似于Behavior Designer)编写剧情,会是多么感人,完全是一条线!

使用剧本编写的NPC对话

这个时候剧情脚本的优势就凸显了,完全线性,逻辑清晰,看剧本就像是读一整个故事一样,由剧作人员接手编写,没有复杂的逻辑,策划能够将心思全部放在故事上面,比如策划提出了某些需求,程序只需要提供相应的接口就完全可以满足(类似于节点编辑器中的节点)

这种做法事实上在gal引擎中非常常见,日本的知名gal引擎KRKR(中文译名吉里吉里,Fate/stay night使用的引擎)中就使用了两种不同的脚本语言来进行编写,一种为线性语言(KAG3),用于描述故事,而另一种则是用于编写引擎扩展(TJS),实现复杂功能,这就同我们当前项目中所采用的Lua+DSL如出一辙,不仅有极强的扩展性,对于策划而言也更为友好(况且实现成本也不高)。

剧情脚本介绍

基本语法

事实上这个DSL的语法非常简单:

函数名@参数1|参数2…

例如:

log@输出信息

也加入了一些简单的语法来减少配置量

例如“~”可以用来替代上一次使用的函数

例如:

第二句调用的函数与第一句相同。

或者也可以通过“*”来表示上一次使用的对应位置的参数。

与上一句的参数完全相同

宏与表达式

当然,因为策划需要获取我们的变量名,所以我们就需要宏的机制。

<<宏名称>>可以代表被替换的宏,例如角色名、角色等级等等。

而且宏是支持表达式的。(事实上这只是简单地将表达式转换为了Lua语句而已,此处需要用到简单的词法解析,我直接用正则做了)

类似于 < <PlayerLv > 30 and PlayerLv < 60>>

上面的语句就会返回True或者false

而如果是以LUA<<Lua代码>>出现的则是直接运行Lua代码并且以结果替换内容。

例如:LUA<<1+1>>

返回2,在Lua中可以做的都可以直接嵌入脚本。

后面提到的If、Switch、Condition函数都是基于表达式来做到分支的。

跳转

另外一个比较重要的概念就是Tag,我们可以在剧本中插入Tag,策划可以根据自己的需要来跳转剧本。

例如:tag@testtag

则是定义了一个tag。

当策划希望进行跳转的时候就会直接使用:

gototag@testtag

各类的分支都是基于这个来做的。

分支

目前使用了三种方式来进行逻辑分支

  • If语句

如果表达式为true,前往tag1,否则前往tag2

if@<<条件表达式>>|tag1|tag2

  • switch语句

求值,如果与某个值匹配则跳转相应的tag,否则继续执行

switch@<<条件表达式>>|值1,值2,ect…|tag1,tag2,ect…

  • condition语句

多路条件判断

对多个条件进行判断,如果满足某一个则跳转,否则继续执行

condition@<<条件表达式>>|tag1|<<条件表达式2>>|tag2

脚本的基石:行为队列

说实话,要实现这个DSL非常简单,也就是逐行解析,每一行代表的就是一个命令。当策划对功能感到不够用的时候就添加新的命令,就像KRKR中包含了包含音频、视频、对话等各种接口,我们根据自己游戏的需要自行实现就可以了。

这里需要讲讲的行为队列。

行为队列其实就是队列,只不过里面放着的是行为罢了。队列最前端执行完成之后自动执行下一个行为,这就让我们能够做各类异步的行为了。

行为队列的用法

行为队列使用类似于下面的代码:

ActionQueue queue = new ActionQueue();
queue.AddAction((callback)=>{
	//任意方法也可以是异步的
	callback();
}).AddAction((callback)=>{
	//任意方法
	callback();
});

我们可以看到,我们传入的行为必须调用callback,这个callback就意味着下一个行为出队并且执行。

行为队列非常有用,如果没有行为对列的话我们可能会掉入callback hell——无尽的回调

DoSomething(()=>{
	DoNext(()=>{
		DoMoreNext(()=>{
			//ect...
		})
	})
});

而在行为队列中则可以做到线性思维:

ActionQueue queue = new AcitonQueue();
queue.AddAction(DoSomething)
	.AddAction(DoNext)
	.Action(DoMoreNext);

队列则会乖乖地为你依次执行。

行为队列的陷阱:中断处理

这个时候我们或许已经跃跃欲试想用行为队列做很多事情,例如异步加载场景时可以线性列出我们需要加载的东西,或者是剧情所需要的逐个行为。

但是这个时候我们必须引起警惕的是中断处理。

例如:我们编写了一个代码

ActionQueue queue = new AcitonQueue()
queue.AddAction((callback)=>{
	//监听完成事件
	//当收到完成事件时调用callback,并且清理事件
});

与普通的队列一样,我们的队列中存在着Clear函数。

当我们调用Clear时我们以为我们做完了所有事情,因为队列也停下来了,我们什么都不用管了吧?

实际上,大家思路缜密的话就会发现我们需要卸载事件监听,否则在事件触发的时候则会尝试对队列出队,造成意想不到的错误。(即使没有被调用内存也泄漏了,事件没有被释放)

所以正确的方式应该是这样

ActionQueue queue = new AcitonQueue();
queue.AddAction((callback)=>{
	//监听完成事件
	//当收到完成事件时调用callback,并且清理事件
}, ()=>{
	//卸载事件
});

当中断的时候调用当前行为的卸载方法。

更进一步的话我们可以将行为包装成一个对象:

class SingleAction : IDisposable{
	Action<Action> act;//行为
	Action dispose;//卸载方法
	
	void Dispose(){
		dispose();	
	}
}

当然,这里只是简单写了一个样例,如何封装一百个人有一百种做法。

在我们的脚本系统当中,实际上就是进一步封装的行为而已,里面包含了上下文、参数等等,以帮助脚本系统更好地工作。

总结

就像众多的可视化编辑器一样,虽然脚本并非可视化编辑器,但是我认为其效率更高并且更加直观,就像编剧编写剧本,后期剪辑视频一样,策划可以更轻易地编写剧情、任务流程等等,并且实现成本不高,有效解放了程序的生产力,在这里只是与大家做一个简单的分享,说不定早就有公司在这样做了吧!

我的资历尚浅,如果有更好地任务、剧情解决方案的话还请大家多多分享,学习交流。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注