今日(きょう)(さわ)がしく(たわむ)れ生きる人々の漫画映画(まんがえいが)

尝试 Ink - 用来写剧情脚本的语言

了解了一下 Inkle 出品的 ink ,但是初步试起来有一点问题,主要是文本本身的结构太多变,然后就没法本地化。(这也许也能解释为什么 Inkle 的游戏大多都没有本地化……)

本来有点想法是自己再设计一种简单的语法的,但明显自己水平不够嘛。这里尝试汇总一下一些常用的语法,把一些没必要的语法过滤掉,顺便看看能不能弄出一套 gettext 比较好处理的规范来。

(参考的是 这里 ,不知道哪里还有更详细的语法标准……)

(说实话有一点点在写汇编的感觉。非常不满的就是它虽然是“脚本”语言但是基本的程序语言的功能都好像要用汇编的方式来手动实现。看它用户手册你会发现一大半内容都是放在 “advanced” 的范畴里的,其中就包括一些非常基础的东西(比如一种会返回到调用位置的函数,真是太高级了;看我们函数还可以递归呢)。)

基本使用方式

体验 ink 最简单的方式就是使用官方的 Inky 编辑器,可以实时预览纯文本的分支效果,也可以使用 Export for web… 功能直接生成可以以纯文本方式游玩的游戏。但想要把 ink 套到非纯文本游戏下的话,就需要使用对应语言的 ink 的库。(如 JavaScript 的 ,如 Java 的 。)

使用库需要先把 ink 脚本编译为对应的 JSON 文件。用库读的时候大多都是一行一行地读出来的,所以没有使用一些奇怪语法的话,用 gettext 提取文本的时候可以以一行为单位。但是要完全利用 gettext 的话还是需要自己设计一下每行的语法。

如果想要使用自己写的简单脚本来提取 .ink 文件的文本给 gettext 的话,与普通文本同一行的一些语法(如同行的注释,同行的标签)都最好不要使用,否则这个提取脚本可能要写得复杂一些。另一个思路是直接提取生成的 JSON 文件的字符串,里面的以 ^ 开头的字符串(除了 "^->" 以外)都应该是文本的句子。如果整个 ink 的结构没有太邪门,编程获取的每行文字应该可以和这些对应的上,这样就可以交给 gettext 处理了。

简单的语法以及一些舍弃的语法

本来是按着教程里的顺序来梳理的,但似乎给内容重新排个序更好一些。

正文

没有以特殊符号开始的内容大多都是正文。 1 如果真的开头有特殊符号,可以使用 \ 反斜杠来转义掉。每一行最前面的空白字符会被忽略掉。

也是很熟悉, \ 也可以用来续行。续行效果有时候有点玄学……有些地方会报错,大多地方会多一个空格出来。

熟悉的注释格式

你好世界这里是正文 // 单行注释
/*
 * 多行注释
 */
1

有一种是以 TODO: 开始的文本,用途即如其名。但我个人觉得不太优雅,请自行决定是否使用。 TODO: 或者 TODO<空格或符号> 可能引擎内部是当做注释处理的,无法 \ 续行,更不优雅了。

Knots

ink 可以把文本分块,传统的章、节、段什么的在 ink 里统一叫做 knot 。Knot 以及下面的 stitch 可以当作是不会保存现场不会返回的函数,可以带参数。段与段之间要用跳转来跳转,有个跳转的语法可以实现读完某段剧情之后自动回到上下文。

先给个例子

// 故事先从第一章开始
-> Chapter_1

=== Chapter_1 ===
第一章
惯例的谜语人对话 blah blah
// 然后去到第一幕
-> Act_1

= Act_1
第一幕
// 去个支线自动返回
-> Side_Quests.Quest_1 ->
返回结束
-> END

=== Side_Quests ===
= Quest_1
支线 blah blah
// 自动返回前一个地方
->->

Knot 格式

格式是两个或以上的等号开始,加上可选的空白字符,加上 knot 的命名(字母以及下划线),加上可选的空白字符或者连续的等号。例子:

== Chapter_1 ==
内容...
==Chapter_1(param1, param2)==
内容...
==Chapter_1
内容...
           ======= Paragraph_20
           内容...

因为前置空格会全部去掉,所以这些行是可以好好缩进的。可以把每一个这样的 knot 以及后续的 stitch 以及后面的选择支理解成大大一条沟,当剧情走到一块内容的最后是不能再走的,必须要手动 跳转 或者在 会返回的 的块里返回。

另外因为 条件多行序列块 的语法相似(相同?),可能最好避免 stopping cycle shuffle once else 这些命名。

注解

文档说 knot 后跟随的内容是包含在 knot 里的,那么 给 knot 的标签 应该就适用于包含的所有内容。但是因为 knot 似乎是没有层级之分的,所以就算一章和一段有明显的包含关系,实际上它们可能其实标签是不互通的?这点还需要进一步验证,总之可以注意一下。

Knot 需要配合跳转才能实现真正的功能。这个功能其实也很熟悉……虽然 ink 自己叫 divert ,但不就是 goto 嘛。考虑到 goto 的名声,可以考虑去回忆一下 goto 被骂的原因再来制定自己的使用规范。

跳转

基本格式就是 -> knot_name 。有两个特别的名字是 ENDDONE ,意思很明确。ink 要求故事结束的时候要有明确的 -> END 这种标志,否则无路可走会报错。

有一种用法是还没有换行就跳转到别处,最后连成一条句子的,包括行中的跳转以及 glue 语法。很多时候这样没法翻译,请不要这样做。

请不要尝试弄什么死循环,inky 会直接卡死,请勤保存。

Stitches

Knot 只提供了一层的层级结构,很多时候是不够用的。ink 提供了 stitch 的 knot 的子结构,可以缓解一部分这种问题。(当然这也就两层。)

语法是在声明 knot 之后用一个等号开始一行,后面接名称。同样可以随意缩进。

=== the_orient_express ===
  ...
  = in_first_class
    ...
  = in_third_class
    ...
  = in_the_guards_van
    ...
  = missed_the_train
    ...

无论是 knot 还是 stitch,它们都没有 fall through 机制,到达一段的终点时必须要手动指明接下来往哪走,无路可走会报错。

跳转用的是 -> knot_name.stitch_name 的语法,同一个 knot 下的 stitch 可以直接 ->stitch_name 跳转。

注解

还是 goto 的问题。如果用到了 ->some_other_knot.stitch 的话,可以考虑一下是不是流程逻辑有些混乱了。毕竟这里不是函数它不会返回。

注解

虽然说没有 fall through 机制,但是如果上面的例子里 the_orient_express 这个 knot 和 in_first_class 这个第一个 stitch 之间没有其它内容,那么 -> the_orient_express 就等价于 -> the_orient_express.in_first_class 。最好还是不要用这种功能比较好。

会返回的

语法是: -> some_knot_or_stitch -> ,基本上就是表示把当前指针压栈了。在 some_knot_or_stitch 里相应地需要使用 ->-> 来返回。

啊,又一个语法糖,可以连起来: -> one -> two -> three ->

Tags

单行文本的标签

给某一行或是某一段添加标签,标签可以用编程的方式读取。语法是 # tagname ,每标签实际的字符串内容应该是经过 trim 处理(把前后空白字符去除)了的,井号不加空格也可以。多个标签就是 #tag1 #tag2 。同样,在标签内外都可以使用 \ 来转义井号。

标签可以放在需要修饰的文本的前一行,也可以放在同一行的最后。

Click the \# button. #tag1\#tag1 #tag2

# tag1-for-hello
# tag2-for-hello
hello # tag3-for-hello

但是如果决定要在文本里添加自己设计的格式的话,部分标签的功能可能其实使用自定义格式会更好。(如在文本里直接使用类似 HTML 标签来表明文本的显示特效。)

给 knot 的标签

格式是这个样子的:

=== Munich ==
# location: Germany
# overview: munich.ogg
# require: Train ticket
First line of content in the knot.

这里的标签用的冒号格式其实不是什么格式,应该是需要外部程序自己去再次解读的,不必太在意。

选择支

因为前置空格全部是忽略的,所以也没有办法区分缩进,所以同一块儿的选择支全部会被归入到同一组里。简单的选择层叠使用多个 * 号或多个 + 号即可,同一行内不能混用 *+ ,不必与上一层的符号保持一致。

警告

[some text] 的特殊语法,但这样对 gettext 以及翻译都极其不友好,请只使用下面的整行括起来的语法。

Please select:
* [Design your own scripting language]
  and fail
* (ink) Use Ink by Inkle
  and just do not plan to translate your game
+ + Use Inky the IDE
    Why not?
* * Use some command-line tools.
- - So what?
* Wrap Ink up
  with some tools and libraries
* ->
  This is a fallback choice.
  -> END
- -> END
/* '*' 字符所在的那一行,除去 '*' 就是选项支的文本;
 * 选择了选项支之后,默认会有选项支文本的回显;
 * 用 '[' 和 ']' 括住整行文本来取消回显;
 * 选择了之后,会继续本选项支一行一行往下走,直至本支完成。
 */

一般来说一个选项支必须最后有一个去路,例如在最后有一个跳转。一个或多个 - 的那些行表示给那些无路可走的选项收尾,不必手动跳转了。这些功能叫做 weave 和 gather 。(明明是一些最普通的功能却放到教程第二部分还弄了些专用名词……真的越来越绝望了。)

可以在文本最开头使用 (name) 给选择支以及 gather 命名,位置需要比条件的更前,命的名可以用于条件以及跳转。使用的语法也是和 Stitches 里的一样,有 knot.stitch.option 这种引用方法。

注解

但是这种用法嘛……

可以参考 一些逻辑的替换方法 ,如果是要判断 第一章.山洞.捡起石头 or 第二章.荒漠.捡起石头 or 第三章.xxx.yyy 的话,那么其实直接用一个变量(ink 自带的或者用外部的状态管理代码)还更好一些。如果多次用到这种跨越层级的引用,可能需要想一想其它优化方法。

如果外面有个循环的话,可以察觉到 * 开头选项本身选过一次之后就再也不能选了(从可选选项中消失了)。这个时候到最后没有选项可选的时候,上面例子的 fallback choice 会自动被选中。要指定 fallback choice 只要是选项的内容为空即可,可以:

* -> somewhere_else
// 或者
* ->
  -> somewhere_else
// 或者,下面这个会报警告
*
  -> somewhere_else

如果想要让一个选项不会消失,使用 + 代替 * 开头即可。fallback choice 也可以用 + 代替 * 同样效果,否则也会消失。

选择支可以在最前面加上用大括号括起来的“条件”,用于判断这个选项是否应该出现。如:

* { has_been_somewhere } I've been there.

注意不要和 序列 弄混,在这个位置的大括号括起来的一定会被当成条件,除非你进行一个空格的转义:

* { cond1 }
  { cond2 }
  Some normal option.
* { has_been_somewhere }
  \ {选项文本|回显的选项文本|第二次的选项文本}
* { has_been_somewhere }
  [\ {选项文本|第二次的选项文本}]

就,好累啊。

Threads

基本就是可以从以往的一条故事主干变为多条故事主干。最终看起来的效果是:

  1. 分支出第 N 条故事分支;

  2. 从第 N-1 条故事分支继续,如普通主干一样,显示所有文本直至遇到选择支或内容完结;

  3. 遇到选择支后,先保留选择支,开始第 N 条故事分支,继续显示所有文本,直至选择支或内容完结;

  4. 如果在上述途中遇到新的故事分支,则继续重复 0~2 步骤,直至没有新的故事分支;

  5. 把所有分支里保留的选择支汇总成一个整体的选择支,玩家选择后,抛弃所有故事分支,以重新以选中的选择支为单一故事主干。

可以看看下面随便的例子。

例子:侃大山

注意这个简单的例子里已经必须使用条件来控制内容了。

例子: -> start
=== start ===
<- from_where
<- where_to_go
* ->
  -> fin

= where_to_go
{ had_fries: <- fries}
* (had_fries) 我们将去向何方?
- -> start

= fries
* 待会儿去整点薯条
* 去码头整点薯条
- -> start

= from_where
* 我们来自何处?
-> start

= fin
-> END

序列

名称是 sequences 。 这里的语法只能单行,可以使用 多行序列块 来使用多行。 \ 续行的地方有点讲究。 语法是 {1|2|3} 会在第一、第二、第三次访问此处时显示对应的内容。大括号里没有 | 的话会被识别为条件。

{1|2|end} // 第三次以及以后都停留在 end 了
{&1|2|next back to 1} // 会循环内容
{!1|2|nothing else} // 第三次后消失
{~random1|random2} // 不按顺序,随机

里面的内容其实是可以使用例如跳转语句等功能的,甚至你可以嵌套(绝对不推荐嵌套)……但毕竟不能换行。就,内容多的话多用跳转结构化一点吧……

注解

这里可以适当参考一下 一些逻辑的替换方法 。因为例如自带的随机 {~win|lose} 其实太随意了。因为这里面其实带了一点逻辑但是又缺少调整的自由度,比如日后想要调整难度曲线,调整输赢的概率,你可能会发现 30% 的胜率只能调成这个样子 {~win|win|win|lose|lose|lose|lose|lose|lose|lose} 至于其它奇怪的 23% 胜率就会变成噩梦。之后说的需要养成少用 ink 的自带逻辑的习惯也是这个道理。

多行序列块

// Sequence: go through the alternatives, and stick on last
{ stopping:
    -       I entered the casino.
    -  I entered the casino again.
    -  Once more, I went inside.
}
// Cycle: show each in turn, and then cycle
{ cycle:
    - I held my breath.
    - I waited impatiently.
    - I paused.
}
// Once: show each, once, in turn, until all have been shown
{ once:
    - Would my luck hold?
    - Could I win the hand?
}
// Shuffle: show one at random
{ shuffle:
    -       Ace of Hearts.
    -       King of Spades.
    -       2 of Diamonds.
        'You lose this time!' crowed the croupier.
}

多文件

INCLUDE file.ink

Inky 编辑器在 INCLUDE 不存在的文件时似乎会崩溃的样子。不做多余的尝试了。功能就相当于把其它文件内容复制过来而已。

脚本语言语法内容

就是那种 scripting language 了,因为 ink 里也有一套数据格式、运算操作以及逻辑判断方式。我个人可能会尽量把这方面的逻辑挪出 ink 的范畴,也就是使用 ink 的 EXTERNAL 把逻辑完全交由外部的 Java 或是 JavaScript 的专门的游戏状态管理器来处理。养成了用默认逻辑的习惯可能会有些麻烦。

条件

条件部分功能是控制 选择支 或者部分文本的显示与否。文本的单行语法是 {cond: TextIf|TextElse} ,多行语法是:

{ cond:
    - TextIf
    - TextElse
}

因为多行语法和 多行序列块 相同,所以如果 cond 和多行序列块的关键词撞了就会很尴尬。其实这是一种 switch 格式的简写:

{ value:
  - match1: content1
  - match2: content2
  - match3: content3
  - else:   content4
}

条件里一是可以使用变量(knot 以及 stitch 的名称算是访问次数的变量),二是可以使用函数。自带的函数有 CHOICE_COUNT() , TURNS() , TURNS_SINCE(-> knot) , SEED_RANDOM(your_seed) 。(TURNS 的回合的意思是玩家选择选项选了几次,TURNS_SINCE 里要用特殊语法了。)

变量

VAR name = "" CONST name = ""

函数

既然决定了要使用外部的函数了,那就请直接放弃 ink 自带的函数吧。

代码部分

在开头使用 ~ 表明本行剩下的为代码。

一些逻辑的替换方法

其实主要看外部函数的设计。

外部函数

EXTERNAL funcName(arg1, arg2, ...) 来声明外部函数。后续使用时外部代码需要先在相应库里绑定对应的函数名。

控制选项的回显内容

"What's that?" my master asked.
*   "I am somewhat tired[."]," I repeated.
    "Really," he responded. "How deleterious."

语法糖影响阅读影响翻译。请直接写成:

"What's that?" my master asked.
*   ["I am somewhat tired."]
    "I am somewhat tired," I repeated.
    "Really," he responded. "How deleterious."

Glue

(这种结构鬼能翻译得了啊。)

=== hurry_home ===
We hurried home <>
-> to_savile_row

=== to_savile_row ===
to Savile Row
-> as_fast_as_we_could

=== as_fast_as_we_could ===
<> as fast as we could.

例子太简单,可以用枚举。如果是固定结构的可以尝试结合外部函数:

~ interpolate("$where", "to Savile Row")
~ interpolate("$how", "as fast as we could")
# interpolation
We hurried home \{$where\} \{$how\}.

这样至少还有一点点翻译的希望。(在 JSON 文件里字符串还是会被储存为 "^..." 的样子,所以可以提取出 "^to Savile Row" , "^as fast as we could" , "^We hurried home {$where} {$how}." 来翻译了。 "^$where" , "^$how" 也会出现,但是我相信稍微设计一下格式应该就可以让译者明白不要翻译这些了。(翻译了也没用,也可以识别格式直接不提取这种文本。))

外置条件

例子有这种:

*   { not visit_paris }     [Go to Paris] -> visit_paris
+   { visit_paris    }              [Return to Paris] -> visit_paris
*   { visit_paris.met_estelle } [ Telephone Mme Estelle ] -> phone_estelle

可能还是有意识的设置变量会更好。

=== visit_paris ===
...
= met_estelle
...
~ set("$gotEstellePhoneNum", 1)
...
=== else_where ===
...
= some_event
~ set("$gotEstellePhoneNum", 1)
...
=== here ===
...
*   { get("$gotEstellePhoneNum") }
    [ Telephone Mme Estelle ]
    -> phone_estelle

下面这种逻辑如果真的有的话到时再想吧……

*   { not (visit_paris or visit_rome) && (visit_london || visit_new_york) }
    [ Wait. Go where? I'm confused. ]
    -> visit_someplace

随机

外部函数。

{ random(20, 20, 25, 35):
- 0: Something
- 1: Something else
- 2: Some other things
- 3: Uhh...
}

评论