打造从twine到dialogue system的工作流

RaHsu

Dialogue System是一个Unity的对话系统插件,如果你看到这篇文章,那么默认你已经对Dialogue System有所了解和使用,想要改善你的工作流。毕竟大段的剧情文本不可能每天开着unity来写吧。如果你还不了解Dialogue System,可以查看他们的官网和官方文档,链接我会附在文章的最后。

twine是什么

Twine是一个开源工具,用于创建交互式非线性故事。它允许用户通过简单的拖放界面和文本编辑来构建复杂的故事结构,而无需编程知识,并且最后可以帮助你生成自己的文字剧情游戏。

Twine支持多种故事格式,如Harlowe、SugarCube和Snowman等,每种格式都有其独特的语法和功能。用户可以根据自己的需求和喜好选择合适的故事格式。

而Harlowe是其中的一种故事格式,也是dialogue system支持的唯一一个故事格式,Harlowe同时也是一个标记语言,如果你会markdown的话,应该可以快速的理解并使用它。

为什么选择twine而不是Atricy:draft

其实类似的剧本创作工具有很多,比如Atricy:draft,YarnSpinner等等,这些工具我也尝试过,但是最终还是选择了Twine,原因如下:

  1. Twine相对而言更加轻量,Atricy:draft过重,需要管理的资产很多,上手门槛也比较高,需要理解Atricy:draft的各种概念,对于小型游戏或是简单的剧本创作来说,Twine更加简洁,让用户更加专注于故事编写。
  2. Twine本质上是一个web应用,所以可以在全平台上甚至是浏览器上运行,但是Atricy:draft只支持windows平台,这对于使用多设备办公的人或者是团队而言局限比较大。
  3. Twine是一个开源应用,使用它进行故事创作是免费的,你甚至可以fork它用于定制自己的版本,而Atricy:draft是闭源软件,并且会根据故事体量收取一定费用。

而YarnSpinner和Twine比较相近,但是YarnSpinner对于非线性叙事的组织结构的展示没有那么直观,更适合线性剧本的编写。

当然,大家可以根据自己的项目情况来选择合适的工具。

Harlowe语法速通

Harlowe的功能很多也很完善,提供了很多语法和宏命令,但是dialogue system目前只支持部分功能和语法,这里只指讲述这部分语法,如果想要学习完整的语法,可以查看Harlowe的文档

链接用于将一个段落连接到另一个段落,也可以用来为用户提供对话选项,比如:

1
2
3
4
这是一个故事的开头
[[这是第一个选项]]
[[这是第二个选项]]
[[这是第三个选项显示的标题->这是第三个选项]]

->前面的为链接文本,后面的为要链接到的段落,没有->视为简写,则链接文本和链接到的段落一致。
得到的结果就会像这样
链接使用示例

Actors 演员

如果要在对话中指定演员的话,在文本开头包含演员的名字即可:

1
garate: 这是我说的一句话

在dialogue system中,需要提前在Actors中注册演员。

Cutscene Sequences 过场动画序列

需要在对话中使用过场动画序列时,直接在段落的最后写上序列的关键词Sequence,之后所有的行都会被解释于序列命令:

1
2
3
4
Hello, world!
Sequence:
AudioWait(hello);
AnimatorPlay(wave)
Script 脚本

要指定脚本字段,请在单独的行中写入Script,后跟 Lua 表达式。例子:

1
2
Script:
Variable["saidHello"] = true
Conditions 条件

要指定条件字段,请在单独的行中写入Conditions,后跟 Lua 表达式。例子:

1
2
Conditions:
Variable["saidHello"] == false

默认情况下,条件设置为 false 时阻止。要使条件直通,请将“(passthrough)”添加到条件:行。例子:

1
2
3
Hello, world!
Conditions: (passthrough)
Variable["saidHello"] == false
描述

要指定描述字段,请在单独的行中写入Description,然后接上你的描述:

1
2
3
Hello, traveller.
Description:
Generic greeting if NPC doesn't have a quest for the player.

当然,这些命令都可以同时包含在一个段落中

Harlow提供的宏命令很多,但是dialogue system目前仅支持(if:)(set:)

(if:) 条件宏

在条件宏中写出你的判断条件即可,if 宏仅支持结果为布尔值的表达时,比如:

1
(if: $legs is 8)[You're a spider!]

后面[]中包含的则是判断通过后的内容。

在dialogue system中(if:)宏中的内容会被写入到Conditions中。

(set:) 设置宏

设置宏用来为变量赋值,变量由$开头,后为变量的名称:

1
(set: $battlecry to "Save a " + $favouritefood + " for me!")

在dialogue system中,变量需要提前注册在dialogue的变量数据库中。并且赋值语句会被添加到dialogue节点的Script中。

从twine到dialogue system

那么如何将在twine中写好的故事在dialogue system中应用呢?

导出故事为json格式

首先你需要在twine中安装Twison 故事格式,这个格式用于将故事作为json格式导出,用于导入到dialogue system或其它的引擎工具中。

在Twine-> Story Formats -> Add的安装输入框中填写下面的地址https://lazerwalker.com/twison/format.js,点击添加,就会将Twison格式安装到Twine中。

安装Twison

然后准备好你写好的剧本,然后在Story->Details中将剧本的故事格式改为Twison,点击Build->play就会生成一个json字符串,将该字符串保存在JSON文件中。

将JSON导入到dialogue system中

在 Unity 中,选择Tools → Pixel Crushers → Dialogue System → Import > Twine 2 (Twison) 。这将打开一个类似于下图所示的窗口。
导入文件

注意:注意:第一次选择此菜单项时,对话系统将询问您是否要启用 Twine 导入功能。单击启用。对话系统重新编译并启用 Twine 导入功能后,再次选择菜单项。

点击导入按钮,故事就会被导入到conversion中。

自定义功能扩展

可以看到,Dialogue System目前对Harlowe的支持不是特别全面,比如如果你想要设定dialogue节点上的其他选项,比如添加一些fields,目前就设置不了。

当然你可以通过联系Dialogue System官方,希望他们支持某些功能,但是反馈到开发,发行新版本的时间无疑很长,并且官方增加的功能或许也难以完全匹配我们的需求,所以我们可以选择自行进行功能扩展。

Dialogue System关于Twine故事导入到代码位于Assets\Plugins\Pixel Crushers\Dialogue System\Scripts\Importers\Twine\TwineImporter.cs中,仔细阅读代码之后,发现其中的逻辑并不复杂,所以我们可以自行修改代码来进行功能扩展(修改之前先将原来的文件备份,防止改不回来)。

整个TwineImporter的整体逻辑为解析JSON->根据JSON内容动态创建dialogue node -> 将dialogue node组织为conversation。

首先我们来分析一下Twison格式的结构,一下是一段简单的Harlowe格式的故事片段

1
2
3
4
5
6
7
8
9
10
11
12
你好啊
[[你也好啊]]
[[我不太好]]
Conditions:
Variable["saidHello"] == false
Sequence:
AudioWait(hello);
AnimatorPlay(wave)
Script:
Variable["saidHello"] = true
Description:
Generic greeting if NPC doesn't have a quest for the player.

得到的Twison格式内容如下:

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
{
"passages": [
{
"text": "你好啊\n[[你也好啊]]\n[[我不太好]]\nConditions:\nVariable[\"saidHello\"] == false\nSequence:\nAudioWait(hello);\nAnimatorPlay(wave)\nScript:\nVariable[\"saidHello\"] = true\nDescription:\nGeneric greeting if NPC doesn't have a quest for the player.",
"links": [
{
"name": "你也好啊",
"link": "你也好啊",
"pid": "2"
},
{
"name": "我不太好",
"link": "我不太好",
"pid": "3"
}
],
"name": "Untitled Passage",
"pid": "1",
"position": {
"x": "325",
"y": "125"
}
},
{
"text": "",
"name": "你也好啊",
"pid": "2",
"position": {
"x": "237.5",
"y": "300"
}
},
{
"text": "",
"name": "我不太好",
"pid": "3",
"position": {
"x": "362.5",
"y": "300"
}
}
],
"name": "Untitled Story",
"startnode": "1",
"creator": "Twine",
"creator-version": "2.10.0",
"ifid": "37BE0223-81C5-4556-9DB2-0F30076FE3F9"
}

结合代码,可以看到TwineImporter就是通过对passages节点的遍历和解析得到一个个dialogue节点的。那么了解大致流程之后,我们就可以在代码中自定义我们需要的功能了。

比如,我想要一个为dialogue节点设置Field字段,那么可以先规定语法,比如我们规定,在Fields字段后的每一行为一个字段设置语句,=前为字段名,后为字段的值。像这样:

1
2
3
4
Fields:
sayhallo=false
content=hallo
time=0

我们通过分析代码可以看到,像Sequence和Fields这些内容都是混在text中的,那么对text进行对应内容的提取即可。

在代码中的ExtractSequenceConditionsScriptDescription函数中加入对Fields的提取代码即可

1
ExtractBlock("Fields:", ref text, out fields);

然后在ExtractBlock函数中加入对应的提取代码:

1
2
var fieldsIndex = FindBlockIndex(text, blockIndex, "Fields:");
var rindex = Mathf.Min(sequenceIndex, Mathf.Min(conditionsIndex, Mathf.Min(scriptIndex, fieldsIndex)));

这样我们就拿到了整个Fields字段的内容。

接下来需要对Fields进行解析,解析出字段名和字段值,并通过Dialogue System的API将Fields字段写入到dialogue节点中:

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
if (fields != null && fields.Length > 0) {
var fieldsList = fields.Split(new[] { '\n' });
foreach (var field in fieldsList)
{
var fieldPair = field.Split(new[] { '=' });
Debug.Log(fieldPair[0] + "," + fieldPair[1]);

string key = fieldPair[0].Trim();
string valueStr = fieldPair[1].Trim();

bool isBool;

// 尝试将值转换为 bool
isBool = bool.TryParse(valueStr, out bool boolValue);
if (isBool)
{
Field.SetValue(entry.fields, key, boolValue);
}
// 尝试将值转换为 int
else if (int.TryParse(valueStr, out int intValue))
{
Field.SetValue(entry.fields, key, intValue);
}
// 尝试将值转换为 double
else if (float.TryParse(valueStr, out float floatValue))
{
Field.SetValue(entry.fields, key, floatValue);

}
// 如果都不是,则默认为字符串
else
{
Field.SetValue(entry.fields, key, valueStr);

}

}
}

设置后的结果:
自定义设置field
可以看到,field已经完美的被设置到了节点上。

TwineImporter并不复杂,通过分析之后,想要增加一些其他的你需要的功能肯定也不在话下了。

通过twine构建模板

Dialogue System对模板的支持较弱,仅支持同时存在一个模板。这让我们的开发效率大打折扣。

但是有了twine之后,我们就可以拥有多种模板。

节点模板

由于节点是纯文本内容,那么准备一个文档并通过伟大的复制粘贴即可使用你的模板。

对话模板

如果需要自定义的对话模板,那么你需要在Twine-> Set Story Library Folder中指定你的故事存放的文件夹。指定好之后,你在Twine中创建的每一个故事都会生成一个单独的html文件,通过伟大的复制粘贴你就可以使用你的自己的模板了。

多设备同步

由于Twine的web版本是将故事存储在浏览器的本地存储中,所以想要多设备同步故事内容或多人同步故事内容只能通过使用Twine的桌面版本,并通过版本管理工具如git来同步了。

相关链接

twinery官网
Harlowe的文档
Twison Github
Dialogue System文档
Dialogue System - Twine Import教程
yarnspinner 官网
Atricy:draft官网

  • Title: 打造从twine到dialogue system的工作流
  • Author: RaHsu
  • Created at : 2025-01-07 11:31:08
  • Updated at : 2025-01-07 11:35:09
  • Link: https://www.rahsu.com/Tech/打造从twine到dialogue system的工作流/
  • License: All Rights Reserved © RaHsu