最近工作当中,任务部分的代码写的耦合度非常高并且也不好扩展,由我接手重构之后我自己编写了一个简单的DSL用于帮助策划配置情节,在这里简单记录一下吧。
由于开发时间非常紧,重构时间整个任务以及NPC对话系统只有短短三天而已,实际将DSL完成并且接入也仅仅花了一天的时间而已,也不需要自己过分编写语法解析之类的轮子。
当然如果希望精益求精的话当然可以自己开发一套完整的语言,但是够用就行了,不是吗~
为什么要剧情脚本
相对于游戏逻辑,剧情逻辑更像是传媒学生在剪辑片子,逻辑更加线性,而不像其他逻辑呈现树状结构。
同样的,我们试着思考一下,如果用节点编辑器(类似于Behavior Designer)编写剧情,会是多么感人,完全是一条线!
这个时候剧情脚本的优势就凸显了,完全线性,逻辑清晰,看剧本就像是读一整个故事一样,由剧作人员接手编写,没有复杂的逻辑,策划能够将心思全部放在故事上面,比如策划提出了某些需求,程序只需要提供相应的接口就完全可以满足(类似于节点编辑器中的节点)
这种做法事实上在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();
}
}
当然,这里只是简单写了一个样例,如何封装一百个人有一百种做法。
在我们的脚本系统当中,实际上就是进一步封装的行为而已,里面包含了上下文、参数等等,以帮助脚本系统更好地工作。
总结
就像众多的可视化编辑器一样,虽然脚本并非可视化编辑器,但是我认为其效率更高并且更加直观,就像编剧编写剧本,后期剪辑视频一样,策划可以更轻易地编写剧情、任务流程等等,并且实现成本不高,有效解放了程序的生产力,在这里只是与大家做一个简单的分享,说不定早就有公司在这样做了吧!
我的资历尚浅,如果有更好地任务、剧情解决方案的话还请大家多多分享,学习交流。