Ink-脚本语言动态故事脚本编程-全-

Ink 脚本语言动态故事脚本编程(全)

原文:zh.annas-archive.org/md5/8ad4a2c2d01859e9ea15bf3f5d0ec93f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

使用 ink 脚本语言进行动态故事脚本编写教授你一种易于学习的叙事脚本语言。ink 允许作者无需为每个项目构建全新的系统,即可使用专为简单和高级叙事体验设计的强大标记语言来创建以故事驱动的内 容。结合 ink Unity 集成插件,作者可以与开发者合作,使用 ink 语言编写所有故事内容,并访问其变量、调用函数或使用 Unity 中的代码在故事的不同部分之间移动。

在本书中,我们将从 ink 本身开始。前五章将指导你了解 ink 如何理解故事、管理流程、故事部分之间的移动,以及如何在故事中存储和操作不同的值。这将直接过渡到中间四章,这些章节将介绍如何使用 ink Unity 集成插件及其提供的应用程序编程接口,在 ink 故事和 Unity 项目之间进行通信。

最后,最后三章将重点介绍三个常见用例。我们将从创建对话系统开始,并回顾在使用 ink 和 Unity 时处理数据的一些方法。接下来,我们将探讨如何创建一个高级任务跟踪系统,其中每个 ink 故事都包含一个任务,但 Unity 用于跟踪它们之间的值。最后一个用例将回顾 ink 和 Unity 中的一些常见术语和模式,以帮助开发者开始在项目中使用程序化叙事。

本书面向对象

本书面向寻求叙事驱动项目解决方案的 Unity 开发者以及希望在 Unity 中创建交互式故事项目的作者。为了充分利用本书,需要具备 Unity 开发和相关概念的基本知识。

本书涵盖内容

第一章文本、流程、选择和交织,描述了墨水、流程、选择及其相互关系的核心概念。

第二章节点、分支和循环模式,涵盖了故事划分、节点以及如何在它们之间移动。

第三章序列、循环和文本洗牌,解释了生成动态文本、替代方案及其不同形式的编程方法。

第四章变量、列表和函数,介绍了如何在 ink 中进行不同方式的值存储以及高级编程。

第五章隧道和线程,解释了如何将节点链接在一起,描述了隧道是什么,以及如何将内容拉入新的配置——线程。

第六章Adding and Working with the ink-Unity Integration Plugin,解释了如何定位、安装和验证 ink-Unity 集成插件的安装。

第七章Unity API – Making Choices and Story Progression,介绍了如何在 weave 中选择选项,并使用 Unity 中的 Story API 推进 ink 故事。

第八章Story API – Accessing ink Variables and Functions,解释了如何使用 Story API 从 Unity 访问和使用 ink 变量和函数。

第九章Story API – Observing and Reacting to Story Events,解释了如何使用 Unity 中的 Story API 观察和响应 ink 中的变化。

第十章使用 ink 的对话系统,描述了在 ink 中使用标签、语音标签创建对话系统的通用方法,以及 Unity 中选项的视觉表示如何影响 ink 中的代码。

第十一章任务追踪和分支叙事,在 ink 中提供了一个任务的一般模板,如何从 Unity 跟踪多个任务,以及如何在故事中同步 ink 变量。

第十二章Procedural Storytelling with ink,介绍了程序化叙事的概念,如何在 ink 中开始使用它,以及 ink 中的相同方法如何在 Unity 中工作。

要充分利用本书

您至少需要 Unity 2021.1 和 Inky 0.12.0。所有代码示例已在 Windows 操作系统上测试。然而,它们应该与 Unity 和 Inky 的未来版本兼容。

第十章**、第十一章和*第十二章包含 Unity 项目。当使用来自 GitHub 的 Unity 项目时,请记住在首次打开项目时要耐心,因为 Unity 会重建文件,并且始终在每个项目中打开* SampleScene 场景以查看最终代码。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将有助于您避免与代码复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件,网址为 github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。你可以从这里下载:

static.packt-cdn.com/downloads/9781801819329_ColorImages.pdf.

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个例子:“每次按钮被点击时,Story 方法ChooseChoiceIndex()将使用正确的索引被调用,并且LoadTextAndWeave()方法将被再次调用,刷新currentLinesText的值并更新屏幕上显示的当前按钮...”

代码块设置如下:

public class InkLoader : MonoBehaviour
{
    public TextAsset InkJSONAsset;
    // Start is called before the first frame update
    void Start()
    {
        Story exampleStory = new Story(InkJSONAsset.text);
    }
}

当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将被设置为粗体:

void Start()
{
Story exampleStory = new Story(InkJSONAsset.text);
Debug.Log(exampleStory.Continue());
Debug.Log(exampleStory.Continue());
}

粗体:表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个例子:

  1. 在项目窗口中选择预制按钮。

  2. 检查器视图中,点击标签下拉菜单,然后点击添加 标签…选项。

    小贴士或重要提示

    看起来像这样。

联系我们

我们欢迎读者的反馈。

一般反馈:如果你对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件的主题中提及书名。

勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这个错误。请访问www.packtpub.com/support/errata并填写表格。

盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。

如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com

分享你的想法

一旦你阅读了《使用 ink 脚本语言进行动态故事脚本编写》,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。

你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

第一部分:墨语言基础

在完成本节内容后,你将能够描述墨语言的主要核心概念和模式,以及如何使用它们的语法。本节包含以下章节:

  • 第一章文本、流程、选择和编织

  • 第二章结、转向和循环模式

  • 第三章序列、循环和文本洗牌

  • 第四章变量、列表和函数

  • 第五章隧道和线

第一章:第一章:文本、流程、选择和编织

本章通过考察分支叙事以及 ink 如何支持创建它们,介绍了非线性叙事的核心概念。在这些概念的基础上,本章还回顾了使用线条、它们内部的文本,以及如何将它们结合起来的方法。

在 ink 中创建非线性、交互式叙事的核心元素中,选择被解释,以及如何最佳地使用它们。在需要大型分支结构的情况下,以及如何使用聚集点将这些编织简化为更简单的部分,讨论了选择的编织和集合。

本章将涵盖以下主要内容:

  • 将分支叙事视为流程

  • 创建选择和编织

  • 消失和粘性选择

技术要求

本章中使用的示例,在*.ink文件中,可以在 GitHub 上找到:github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language/tree/main/Chapter1

将分支叙事视为流程

当读者手持一本实体书时,他们通过翻页来阅读故事。页与页之间的移动也是一种在故事中的移动。读者所体验到的被称为叙事。故事是将其内容打包成不同部分的过程,这些部分被称为页面。然而,读者的叙事是他们在这些页面间体验故事的过程。

在数字环境中,没有物理页面。故事中的文字可以存储为简单的文本文件的一部分,或者捆绑在更复杂的东西中。数字故事的各个部分,即实体书中的页面,也可以更容易地排列,读者可能会以不同的配置体验它们,从而从相同的故事内容中创造出新的叙事。

考虑以下示例,其中每个句子都是故事的一部分:

The sun was shining in a clear blue sky.
Clouds rolled in and it began to rain.
The clouds cleared away and the sun emerged.

如果从第一句到最后一句话按顺序阅读,有一个故事,其中主要部分是太阳照耀、云朵到来,但随后云朵离开,太阳再次照耀。然而,如果重新排列这些部分会发生什么呢?

示例 1:

The clouds cleared away and the sun emerged.
The sun was shining in a clear blue sky.
Clouds rolled in and it began to rain.

通过不同的顺序,为读者创造一个新的叙事。在这个版本中,故事的发展从太阳升起和照耀开始。接下来,云朵移动进来,开始下雨。在任何情况下,只使用了三个事件,但它们的顺序影响了读者的叙事体验。

非线性叙事

在第二个例子中,故事仍然是有意义的。然而,这次事件从云彩开始,过渡到阳光照耀,并以云彩返回结束。第二个例子在事件之间的移动,是非线性叙事的一个例子,其中事件或故事的部分以新的或不同的方式体验,不同于创作或最初编写的方式。这种进展不是从故事的一个部分到另一个部分的线性,但仍然创建了一个连贯的叙事:

Figure 1.1 – Mapping nonlinear storytelling

Figure 1.1 – Mapping nonlinear storytelling

图 1.1 – 非线性叙事映射

通过导航非线性故事创建的结构通常被比作。它们从树干开始,然后,随着遇到不同的部分,就会形成一个分支模式,每个分支代表从故事的一端到另一端的部分移动。非线性故事的叙事遍历创建了一个分支叙事,其中不同的部分被或未被遇到。体验到的叙事地图代表了故事整体树及其部分的特定分支。

虽然非线性叙事可以用印刷书籍来完成,但通常要困难得多。在数字环境中,事件可以被分成不同的部分,重新排列它们通常就像选择它们并将它们拖到同一文档的不同部分一样简单。将故事表示为数据所带来的这种后果使得编写处理不同部分排列的代码也变得更容易。这被称为叙事脚本

介绍墨水

墨水是一种叙事脚本语言。它决定了读者接下来应该阅读故事中的哪一部分。当用户点击或按下按钮时,墨水编写的代码会在不同的分支之间做出决定,并确定他们何时应该访问。根据作者编写的规则,代码甚至可以重复相同的故事部分,并使用新的值。

因为墨水是为编写叙事而设计的,所以它将故事中的导航理解为一个特殊的概念,称为。当读者通过不同的部分移动时,他们正在创造自己的体验叙事,墨水称之为流。实际上,作者遇到的最常见的错误之一是故事的流耗尽。即使是在编写具有不同分支的非线性故事时,故事也必须有一个开始和结束。即使每次用户遍历故事的部分时,叙事的开始和结束之间的所有部分都会改变,这两个点仍然定义了可能分支的范围。

文本向下流动

流的概念也扩展到在墨水中如何理解代码。在墨水中,跨越故事的任何移动都是从代码的顶部向下到底部,除非告诉它导航到故事中的不同部分。

本章前面分享的示例故事也是代码示例。ink 旨在创建分支叙事,并提供了编写代码来创建这些结构的能力。这意味着在 ink 中,故事中不包含任何其他特殊字符或语法的文本或单词都是有效的。

ink 中行内的间距很重要。因为文本被视为一种代码形式,ink 假设任何使用间距都是作者有意为之的选择。例如,在单词之间添加额外的空格不会被其输出移除:

The sun was        shining in a clear blue sky.

输出:

The sun was        shining in a clear blue sky.

ink 会忽略任何空行。它假设文本的每一行都是重要的,并且它们之间的任何间距都应该被视为对故事本身不重要的事物而被忽略。

示例 2:

The sun was shining in a clear blue sky.
Clouds rolled in and it began to rain.
The clouds cleared away and the sun emerged.

输出:

The sun was shining in a clear blue sky.
Clouds rolled in and it began to rain.
The clouds cleared away and the sun emerged.

最小的单位是行

在引入非线性叙事时,将三行作为故事的一部分并不是一个错误。ink 故事中最小的单位是一行:

This is a story.

因为 ink 认为文本是代码的一部分,所以只有四个单词的单行是完全有效的故事。添加更多行也会扩展将展示给读者的内容,但单行本身也可以是一个故事:

This is a story in ink.
It has two lines.

在 ink 中,术语的使用很重要。在阅读物理书籍时,故事中最小的单位通常是句子。这通常是更大作品中最小的完整思想。在数字环境中,特别是在 ink 中,行是最小的单位。当 ink 加载故事时,它逐行通过故事。它将每一行视为与最后一行同等重要。

随着更复杂代码的引入,行的概念也会变得更加重要。然而,就像单行示例一样,一个故事不需要复杂才能变得重要。对 ink 来说,一个故事由行组成。这可以是一行,也可能是多行。

粘合行

作者可能需要使用多行文本作为一行“代码”。在这些情况下,ink 提供了一个名为<>的概念,当它们一起使用时,它们会将一行内容粘接到下一行,创建一个长行:

示例 3:

This <>
is <>
considered <>
one <>
line of text.

输出:

This is considered one line of text.

使用粘合剂时的间距很重要。就像单行内的间距一样,ink 尊重作者在单行中呈现文本时的选择。当使用粘合剂时,这些间距也被尊重。

如果每个单词后面没有空格,那么在前面示例中使用的粘合剂会将所有单词粘合在一起:

This<>
is<>
considered<>
one<>
line of text.

输出:

Thisisconsideredoneline of text.

使用注释作为对作者的笔记

作为一种脚本语言,ink 还提供了在故事代码中包含注释的能力。借鉴更通用的编程术语,ink 将这些注释称为注释。它们以两个斜杠开始,然后包含注释的内容。行中的任何部分也被视为注释的一部分:

示例 4:

The sun was shining in a clear blue sky.
// Change this next line in the future.
Clouds rolled in and it began to rain.
// Maybe update this story in a future version?
The clouds cleared away and the sun emerged.

当运行时,故事的文本会被视为其代码。然而,任何注释的使用都不会出现在故事的输出中。注释仅设计给人类观众,允许作者向其他观众解释代码,或者更普遍地,作为作者或其团队其他成员关于如何工作的笔记。

使用 Inky

为了帮助作者更快地在墨水中开发故事,Inkle Studios创建了一个名为Inky的程序。这个编辑工具允许作者编写代码,并预览其输出:

图 1.2 – Inky 编辑器的截图

图 1.2 – Inky 编辑器的截图

虽然最初由 Inkle Studios 开发,但 Inky 现在是一个开源项目,社区经常会有数十个提交来修复小问题或添加新功能。通常每年会推出一个新的小版本。

在撰写本文时,Inky 没有 Windows 安装程序,但为 macOS X 和 Linux 系统提供构建。在 Windows 或 Linux 上运行时,需要将 ZIP 文件解压缩到现有文件夹中,并运行Inky.exe(Windows)或Inky(Linux)文件以打开编辑器。

使用 Inky

Inky 提供了一个具有两个窗格的界面:

  • 左边是编写墨水代码的地方。

  • 右边显示了代码开发时的预览。

这使用户能够快速看到他们的代码将如何根据所使用的代码产生不同的输出。

Inky 最有用的功能是能够“回退”故事到一个较早的点并尝试不同的叙事分支。这允许作者更快地测试故事的不同分支,而不需要每次都重新启动故事。

图 1.3 – “回退单个选择”和“重新启动故事”按钮

图 1.3 – “回退单个选择”和“重新启动故事”按钮

重要提示

本书将使用 Inky 的截图来展示不同代码产生的结果输出。

创建选择和编织

当代码选择故事的一部分来为用户生成一个新的可能叙事时,这可能会很有趣,但大多数用户希望对接下来发生的事情有所输入。他们想要一个互动式的故事。在墨水(ink)中,交互性是通过向用户展示选择来实现的。根据读者做出的选择,叙事可以以不同的方式分支。

制作编织

墨水中的选择是另一个重要概念——编织(weaves)的一部分。当用户从一个部分创建到另一个部分的流程时,他们经常会遇到故事中的交叉点,根据所做出的选择,可能会有分支的可能。这就是墨水中的所谓编织。这些是选择集合,其中每一个都有可能以不同的方式分支故事。

选择在墨水中使用星号*来编写。在墨水中,可能看起来像事物列表的东西,实际上是一个单一的编织中的不同选择:

What did I want to eat?
* Apples
* Oranges
* Pears

在之前的代码中,以星号开头的每一行都是一个选择。它从星号开始,延伸到行的末尾。行中的所有内容都成为选择的一部分。每一行的新星号在编织中创建一个新的选择:

We smiled again at each other across the coffee shop. I had seen her coming in at this same time for over a week now. We had spoken a couple of times, but I could not bring myself to talk to her more.
As I looked back down at my coffee, I needed to decide.
* I decided to go talk to her.
"Uh. Hi!" I said, maybe a little too loud as I approached her.
* I gave up for now. Maybe tomorrow.
I shook my head to myself and looked away from her and out the window. Today was not the day.

在编织中的每个选择都有分支叙事的潜力。在之前的代码中,有两个选择。然而,在每个选择之后还有另一行代码。当运行时,ink 会理解每个选择之后的每一行代码都是读者选择的结果。为了更好地视觉区分选择的结果,选择之后的行通常在开头缩进。

将之前的代码更改为使用缩进将如下所示:

示例 6:

We smiled at each other again across the coffee shop. I had seen her coming in at this same time for over a week now. We had spoken a couple of times, but I could not bring myself to talk to her more.
As I looked back down at my coffee, I needed to decide.
* I decided to go talk to her.
    "Uh. Hi!" I said, maybe a little too loud, as I approached       her.
* I gave up for now. Maybe tomorrow.
    I shook my head to myself and looked away from her and out       the window. Today was not the day.

选择中的选择

选择也可以出现在其他选择内部。这些是子选择,并使用额外的星号来表示它们是编织前一层的结果:

示例 7:

Should I really forgive her again? I thought about the options in front of me as I considered what she told me.
* I forgive her.
    ** She does the same behavior again.
        I just end up hurt again.
    ** She really does change.
        She does not have another affair and maybe we can save           our relationship.
* I do not forgive her.
    ** I would have to move out.
        I would need to find another apartment.
    ** I stay with her and try to live again without being in a          relationship.
        I could try going back to being friends like we were           before our relationship.

在之前的代码中,有两个选择,每个选择都引出自己的选择,从中心集合分支出来。这是一个复杂编织的例子。编织的第一层是初始的两个选择。然后,每个选择的后果是另一个编织,最后以文本结束。根据用户的流程,他们可能在移动到这些部分时只看到整体故事的一部分。

在复杂的编织中,一个可能分支的输出如下,供读者阅读:

输出

Should I really forgive her again? I thought about the options in front of me as I considered what she told me.
I forgive her.
She does the same behavior again.
I just end up hurt again.

故事中的不同分支序列也可能产生以下输出:

Should I really forgive her again? I thought about the options in front of me as I considered what she told me.
I do not forgive her.
I would have to move out.
I would need to find another apartment.

选择性选择输出

使用选择时,选择的文本本身会出现在其输出中。这可以通过使用与选择相关的特殊概念选择性输出来改变。通过在选择的行中的任何文本周围使用开闭方括号,它将不会作为选择的结果出现在输出中:

What did I want to eat?
* [Apples]
* [Oranges]
* [Pears]
I got some food.

在之前的代码中,无论读者做出何种选择,输出结果都会相同:

What did I want to eat?
I got some food.

当选择的内容与展示给读者的内容不同时,使用选项这个词。选择是通过代码在 ink 中创建的。最终展示给读者的内容是一个选项

在更高级的代码示例中,ink 可以动态生成选择。在这些情况下,与选择性输出一样,理解选择作为开发者编写的内容以及选项作为读者选择的内容是很重要的。通常,这些可以是同一件事,但在 ink 中编写代码时,它们不必是同一件事。

选择性输出还允许通过选择性显示选项中的文本来创建更动态的输出。使用选择性输出的一个影响是,一行中的闭合方括号表示向读者展示内容的结束。该行上任何额外的文本都将被忽略:

示例 8:

I looked at the timer again and then at the wires in front of me. I had five seconds to stop this bomb from exploding.
* [I cut the wire.] It was the green one.
* [I cut the wire.] It was the red one.
* [I cut the wire.] It was the blue one.

可能的输出:

I looked at the timer again and then at the wires in front me. I had five seconds to stop this bomb from exploding.
It was the green one.

从读者的角度来看,之前的代码会显示三个选项。每个选项都会读作我剪断了电线。然而,选择性输出的使用是告诉墨迹忽略每个颜色的附加文本。在做出选择后,用户会看到选择的结果作为新的一行,使用方括号排除任何包含的内容。

选择性输出通常可以用来隐藏在读者必须选择一个选项然后才能看到该行的附加文本的信息。

汇集点

编织中的每个选择都可能分支一个叙事。然而,有时需要将一个或多个分支汇集回它们开始的地方。而不是走向新的方向,可以使用汇集点将更复杂的编织折叠成一个中心点。在墨迹中,汇集点是通过在行上使用单个减号(-)来创建的:

You peer down at the desk with two drawers.
* Try to open the top drawer.
    It does not open.
    ** Try again more forcefully.
    ** Give up for now
* Try to open the side drawer.
    It does not open.
    ** Try again more forcefully.
    ** Give up for now
- All the drawers seem locked tight.

在之前的代码中,有两个选择,每个选择下又有两个子选择。然而,在编织的底部有一个汇集点。无论选择哪个分支跨过第一个编织层然后进入下一层,流动总是会汇集在最后一行。这就是汇集点的力量:它们允许一个具有多层复杂编织的结构折叠成一个单一点。

汇集点的放置很重要。在墨迹中,故事从上到下流动。如果汇集点出现在编织之前,它就会被忽略。如果没有东西可以汇集,汇集点就什么也不做。这也只影响编织。在没有编织上方作为折叠点的故事中,多个汇集点将不会产生任何作用。

汇集点一次只作用于一个编织。作为编织的最后一行,它们的作用是汇集选择。然而,它们一次只适用于一个分支结构。每个编织需要一个新的汇集点来将这些分支重新汇集在一起:

示例 9:

You peer down at the desk with two drawers to open.
* [Try the top drawer.]
* [Try the side drawer.]
- All the drawers seem locked tight.
You give up on the drawers and look at the top of the desk.
* [Look at the papers on top of the desk.]
* [Pick up the papers and look through them.]
- You find nothing of interest.

在之前的代码中,既使用了选择性输出也使用了汇集点来创造两个编织,每个编织有两个选择的错觉。由于它们使用了汇集点,每个结果的输出都是各自的最后一行。选项呈现给读者,但代码本身会折叠每个编织的任何可能的分支,并将故事从第一个编织流向第二层。

消失和粘性选择

编织的默认行为是沿着其选择提供的其中一个分支引导故事的流动。当读者做出选择时,其他选项消失,所选择的分支成为故事的当前流动。即使在用 Inky 测试故事时回放,似乎在任何时候编织只有一个有效的分支。

预测读者可能会重新访问故事的一部分,并且可能会看到之前没有见过的选择的情况,墨水使用粘性选择的概念来再次向读者展示相同的选择。使用粘性选择,每次重访时每个选项都保持 开放,并且可以在将来再次使用:

You look at the boulder in front of you.
+ Push the boulder.

粘性选择使用加号 (+) 创建。它们可以被视为聚集点的对立面。而不是折叠编织,粘性选择 保持开放 编织中使用不同分支的选项。任何作为编织一部分创建的粘性选择总是 粘性的,即使它是编织中唯一的:

示例 10:

You look at the boulder in front of you.
+ Push the boulder.
* Ignore it for now.

在之前的代码中,有两种选择:

  • 第一种是粘性选择。

  • 第二次访问代码时,它将被移除。

在示例中,boulder 可以忽略一次,但下次读者再次访问这部分时,他们只会看到一个选项:推动巨石

在故事只从上到下流动的示例中,粘性选择似乎用处不大。做出任何选择后,故事都会沿着分支流向故事的下一个较低部分,而不管选择类型如何:

The blank page stared back at me, taunting me. I glanced again at the clock and then back at the page. I needed to write something.
+ I tried again to write something.
    I wrote a few words and paused.
+ I checked my email again.
    No new messages.

在前面的示例中,有一个包含两个粘性选择的单一编织。在从上到下移动故事时,编织会被访问一次,然后任一选择会分支出去,并在最后再次汇合。

同样的例子可以用其他选择类型来制作。

示例 11:

The blank page stared back at me, taunting me. I glanced again at the clock and then back at the page. I needed to write something.
* I tried again to write something.
    I wrote a few words and paused.
* I checked my email again.
    No new messages.

两个代码示例的不同之处在于它们的意图。在第一个示例中,读者可能可以重新访问故事的同一部分并再次看到选择。在第二个示例中,选择是单向的。通过在编织中做出选择,它们不能在故事中重新访问。一旦做出选择,基本选择就是永久的。唯一改变这种意图的方法是使用粘性选择,当使用时它们会 添加 自己回到编织中。

在下一章,第二章结、转向和循环模式,我们将开始检查循环并控制故事在更复杂结构中的流动。循环将允许我们多次回到故事中的同一部分。在这些情况下,粘性选择将成为为玩家创建选项的默认用法。因为粘性选择保持开放,它们允许作者创建一个编织,玩家可以在其中多次选择相同的选项。

摘要

本章为您提供了对术语故事、内容和叙述的解释,以及读者可能从其内容中体验到的内容。我们探讨了非线性叙述,即故事的部分可以以不同于它们被书写或最初创作的顺序体验。接下来,我们学习了分支叙述,这是一种描述体验非线性故事的方式,其中不同的序列、分支被探索而不是其他。通过使用代码(脚本),我们看到了通过控制读者体验故事内容的时间来创建不同叙述的方法。

ink 是一种叙述脚本语言。我们将通过故事中的移动理解为一个称为流的概念。我们发现,通过使用不同类型的选项创建的每个交叉点都被称为编织物。通过使用选项,我们看到了编织的不同层次和更多分支是可能的。对于编织物变得过于复杂的情况,我们可以使用聚集点。这会将编织物折叠成单个点或线。

在下一章中,我们将开始使用结,故事的部分标记区域,以及偏离,在这些部分之间移动,来构建非线性叙述和分支叙述的概念。我们将开始使用选项将读者移动到特定的结或重复相同的编织物。

问题

  1. 故事和叙述之间的区别是什么?

  2. 墨水如何理解流的概念?

  3. 如何将多行文本合并为一行?

  4. 墨水编织物是由什么制成的?

  5. 有哪些不同的选择类型?

  6. 如何使用选择性输出在选项中隐藏信息以供读者阅读?

  7. 为什么粘性选择可能是向读者展示选项的首选方式?

第二章:第二章: 节点、转向和循环模式

本章介绍了节点的概念,即墨水故事的章节,以及转向,这是在它们之间移动的功能。然后我们将进入定义和移动到节点以创建简单的循环模式。通过结合选择(在第一章文本、流程、选择和编织中介绍),我们将看到您如何开始体验由选择选项、故事在节点之间移动以及使用循环模式从墨水中的简单规则构建复杂交互的叙事。

本章将涵盖以下主要主题:

  • 将流程打结

  • 在部分之间移动

  • 循环节点

  • 检测和更改选项

技术要求

本章中使用的示例,在*.ink文件中,可以在 GitHub 上找到,网址为github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language/tree/main/Chapter2

将流程打结

第一章文本、流程、选择和编织中,选择被解释为能够将故事分支到不同的部分。展示了简单的分支结构,但故事中的移动是从一个编织到另一个的流动。当在墨水中给部分命名时,它成为其核心概念之一:一个==)和节点名称的单行。在此定义之后,直到遇到下一个节点之前的每一行都成为原始节点的一部分。通过给部分命名,它们可以在墨水中导航,为读者创造更复杂的叙事体验。

创建节点

物理书籍通常分为章节。使用节点,数字墨水故事也可以分为不同的部分。虽然小说或教科书可能根据单词章节来命名部分,但数字故事可以超越这些限制,使用地点、角色或其他更抽象的故事划分。

例如,基于侦探与犯罪的不同嫌疑人交谈的墨水故事可能会根据其角色将其分成不同的节点,如下面的代码片段所示:

The detective considered the suspects in front of her.

== lady_taylor

== lord_davies

== sir_jones

== lady_turner

墨水中的节点名称必须遵循以下三个具体规则,如下所述:

  • 它们可以包含数字。

  • 它们可以包含大写和小写字母。

  • 允许的唯一特殊符号是下划线。

节点名称中不能使用空格。没有空格来分隔单词,节点名称通常使用小写字母书写,名称、单词或其他重要细节之间用下划线分隔。

在节点之间转向

创建节点本身如果没有一种方法在它们之间移动,就几乎没有用处。在 ink 中,在节点之间移动被称为 -) 和一个大于号 (>)。这个组合 -> 指示流程将移动到下一个节点,如下面的代码片段所示:

-> example_knot

== example_knot
Some content.

一旦定义,节点就可以被同一故事中的任何其他代码访问。在采用节点的 ink 故事中,代码的第一行是转换操作并不罕见。

使用 DONE 和 END

第一章 中关于“流程”概念介绍的 《文本、流程、选择和交织》 一文中,提到了所有三个之前章节中的代码示例共有的一个特定错误。因为在不同节点之间转换引入了创建复杂叙事的可能性,墨水需要知道故事至少有一个结局才能停止故事 继续进行。为了帮助墨水在故事即将结束时发出信号,所有故事都内置了两个名为 DONEEND 的节点。与使用小写字母和——经常——下划线的其他节点不同,这些节点使用大写字母编写。

DONEEND 之间的区别在于它们的用法。当故事转换到 DONE (-> DONE) 时,它表示当前流程的结束,但不是故事的结束。然而,END 表示所有可能流程的结束,并完全结束一个故事。使用 DONE 允许创建新的流程结构。END 停止故事,不允许发生其他任何事情。

任何转换到非 DONEEND 节点的 ink 故事,都必须在某个时刻转换到其中一个节点,否则故事将无法使用。回到本章前面提到的 example_knot 的使用,一个可用的代码形式会是这样的:

示例 1

-> example_knot
== example_knot
Some content.
-> DONE

在 Inky 中,每条输出末尾显示的短语End of story是使用特殊的节点 END。如果没有内容显示,故事已经结束。在 ink 的术语中,它已经转换到了 END。以下是显示这一点的屏幕截图:

![Figure 2.1 – End of story![Figure 2.1 – End of story 图 2.1 – 故事结束# 在部分之间移动节点允许作者将 ink 故事划分为他们可以命名的部分。转换操作允许在这些节点之间移动。在 第一章《文本、流程、选择和交织》 中,使用星号 (*) 和分支故事的能力引入了选择。将转换作为选择的结果允许作者制作一个交织,一组选择,其中每个选择都可以转换到不同的节点。例如,回到本章前面提到的侦探示例,一个带有选择的更新版本,每个选择都会转换到角色的节点,看起来会是这样:示例 2csThe detective considered the suspects in front of her.* Lady Taylor    -> lady_taylor* Lord Davies    -> lord_davies* Sir Jones    -> sir_jones* Lady Turner    -> lady_turner== lady_taylorStanding off to the side of the gathered crowd and looking out the window was Lady Taylor. She was elegantly dressed in a cream evening gown and the light from the storm outside was a stark contrast to the flowing dress and quiet form of the woman.The detective made her way over to question her.-> DONE== lord_davies"Ah! Detective!" barked the commanding voice of Lord Davies. With a drink in his hand and the red evidence of practiced drinking on his face, he began again. "Over here! I know you will want to hear what happened from me."The detective considered the man and then turned to face him.-> DONE== sir_jonesThe detective turned to the fireplace. Leaning against it was "Sir Jones." The detective knew this was a nickname for the person in front of her. They were neither of the rank "sir" in this area nor was their name "Jones." They had appeared about six months ago at parties like this one and was quite a fixture at this point. No one knew much about them other than that they went by the name "Sir Jones" now.The detective regarded them for a moment and headed over.-> DONE == lady_turnerLady Turner had been crying. The evidence of sorrow was etched into the drying black edges of her makeup at the bottom of her eyes as she tried to clean up her face. As the detective looked over, Lady Turner caught her eye and seemed to communicate how much she did not like to show the evidence of crying on her face and was trying to clear it quickly.The detective walked to her and sat down.-> DONE在更新版本中,增加了一个编织。其中每个选择都会立即转向与一个字符匹配的结点。在结点内部,使用内置的 DONE 结点来让墨水知道在结点内容之后应该停止流程。在新代码中,仅使用选择、转向和结点的三个概念就创建了一个更复杂的故事。## 结点和针脚结点允许墨水故事被分成不同的部分。在结点内部,可以添加额外的子部分,称为 =) 和其名称。针脚遵循与结点相同的命名规则:它们可以包含数字、字母和下划线,但不能使用任何其他特殊字符。针脚也仅可以出现在现有的结点内部。返回到 example_knot 代码,可以添加两个针脚,如下所示:cs-> example_knot== example_knot= stitch_one= stitch_two流程在结点处“跑出”的常见错误也适用于针脚。作为故事的一部分,它们也必须转向另一个结点或针脚,或者使用内置的结点来停止流程或故事。在下面的屏幕截图中,你可以看到一个在针脚中流程跑出的例子:图 2.2 – 示例针脚中流程跑出的错误

图 2.2 – 示例针脚中流程跑出的错误

结点中的第一个针脚是此错误的例外。故事将自动从结点流向第一个针脚。以下给出了一个修正后的示例,考虑到流向第一个针脚并包括额外的转向:

示例 3

-> example_knot
== example_knot
= stitch_one
Diverting to example_knot will automatically show this.
-> DONE
= stitch_two
-> DONE

这将是输出:

图 2.3 – 从 example_knot 转向的 Inky 输出

图 2.3 – 从 example_knot 转向的 Inky 输出

作为墨水故事的自身子部分,针脚也可以直接访问。转向结点内部的针脚遵循 .),它用于转向名称和其中针脚之间的名称。

直接转向前一个代码顶部中的 stitch_two 会产生以下代码:

示例 4

-> example_knot.stitch_two
== example_knot
= stitch_one
Diverting to example_knot will automatically show this.
-> DONE
= stitch_two
This will now appear because the stitch is being diverted to directly.
-> DONE

这将是输出:

图 2.4 – 从 example_knot.stitch_two 针脚转向的墨水输出

图 2.4 – 从 example_knot.stitch_two 针脚转向的墨水输出

在不同文件间划分故事

当将新的结点和针脚添加到单个文件中时,它们可以迅速变得非常长。为了帮助解决这个问题,墨水有一个用于组合文件的关键字:INCLUDE。当与墨水代码一起使用时,INCLUDE 关键字根据其文件名包含另一个文件。

使用 INCLUDE 关键字有以下两个规则:

  • 它应仅用于文件顶部。

  • 它不能在结点内部使用。

在 Inky 中,可以通过使用新包含墨水文件菜单选项向现有项目添加额外的文件,如图所示,并命名新文件:

图 2.5 – Inky 的新包含墨水文件菜单选项

图 2.5 – Inky 的新包含墨水文件菜单选项

使用此功能与主 ink 文件选项一起还会在现有的 ink 文件中添加一个单独的新行,如果它存在的话。例如,创建一个 additionalFile.ink 文件将生成一行额外的代码:INCLUDE additionalFile.ink

警告

Inky 在创建包含的 ink 文件时不会自动将 .ink 添加到文件名中。强烈建议在使用此功能时始终添加文件类型。

每次使用 INCLUDE 都会将文件添加到当前项目中。这意味着这些包含文件中的任何节点和针都可以被其他任何文件访问。因为文件可以根据故事中的位置、角色或其他抽象命名,这允许作者将故事分成不同的文件,每个文件都有自己的节点和针,如下面的代码示例所示:

示例 5

INCLUDE books.ink
You stand in front of a shelf with two books.
* [Red Book]
    -> books.red_book
* [Blue Book]
    -> books.blue_book

在新示例中,每个选择项都会导向另一个文件中的一个针。因为 Inky 使用 INCLUDE 将文件组合成一个单一的项目,所以 books.ink 文件中的节点和针可以像所有代码都是同一个文件的一部分一样访问,如下面的代码示例所示:

示例 5 (books.ink)

== books
= red_book
The red book slides open as a deep, masculine voice fills your mind.
-> DONE
= blue_book
The blue book slowly flips open as a reluctant, feminine voice creeps into your thoughts.
-> DONE

在 ink 中,流程从上到下运行。从第一个文件开始,流程会显示两个选择项的交织。然后选择 Red Book 选项会导向另一个文件中的针,最终导向 DONE 特殊节点中的 divert,如下面的截图所示:

Figure 2.6 – Combined output from Red Book choice in Example 5

Figure 2.6 – Combined output from Red Book choice in Example 5

图 2.6 – 示例 5 中红皮书选择项的合并输出

循环节点

一个节点可以导向自身。这个基本概念是 ink 中高级对话和叙事结构的一个重要部分。然而,当节点导向自身或形成循环模式时,必须小心。很容易创建 无限循环,代码会无限循环而不会停止。为了防止这种错误,始终包含至少一个选择项的内容结束故事或打破循环是一个好主意。

通过结合选择项、分支和节点,可以创建循环结构。在这些结构中,粘性选择对于在每次循环中为读者提供一致的选择选项变得很重要。

循环结构

最基本的循环结构有两个选择项。第一个继续循环,第二个必须以某种方式结束故事,如下面的代码示例所示:

示例 6

You look at the rock in front of you.
-> rock
== rock
* Push the rock up the hill.
    -> rock
* Ignore the rock for now.
    -> DONE

选择项,那些带有星号(*)的,在整个故事中只能使用一次。在先前的例子中,如果选择了第一个选项,循环会重复,但第二个选项随后就会作为唯一的选择出现,如下面的截图所示:

Figure 2.7 – Example 6 choices after one loop

Figure 2.7 – Example 6 choices after one loop

图 2.7 – 循环一次后的 6 个选择示例

在某些故事中,随着读者在故事中的移动减少选项可能效果很好,但对于每个循环都需要相同选项的情况,则需要不同类型的选择:粘性选择

重新访问粘性选择

第一章中,介绍了文本、流程、选择和编织。粘性选择作为从上到下的流程的一部分展示,且不重复任何部分,当时似乎并不太有用。然而,在循环模式中使用转向和结时,粘性选择通常是最佳选择类型,如下面的代码示例所示:

示例 7

You look at the rock in front of you.
-> rock
== rock
+ Push the rock up the hill.
    -> rock
+ Ignore the rock for now.
    -> rock

在更新的代码中,两个选项都是粘性选择。这种新代码允许重复模式和一致的编织选项,无论循环次数如何,如下面的屏幕截图所示:

图 2.8 – 示例 7 多次循环的 Inky 输出

图 2.8 – 示例 7 多次循环的 Inky 输出

检测和更改选项

结和针不是唯一能够循环的墨水概念——选项也可以这样做。它们还具有独特的检测它们是否是循环结构一部分的能力。这些特殊类型的选项被称为标记选项。它们创建了一个能力,可以为选项提供一个标签并跟踪它是否在故事中之前出现过。标签也是变量的一个例子:代码作为故事的一部分改变其值。

标记选项的使用使我们能够使用第二种类型的选项:true,如果选项显示,则该选项可见。如果不显示,则该选项被隐藏。

标记和条件选项

使用括号(())包围一个名称,并在选择符号(一个加号 + 或星号 *)之后,以及选择本身的文本来创建标记选项。标记选项遵循与结和针名称相同的规则:它们可以包含数字、大写和小写字母以及下划线。它们不能包含空格或其他特殊符号。以下代码片段展示了这一点:

You look at the rock in front of you.
-> rock
== rock
+ (push) Push the rock up the hill.
    -> rock
* Push the rock over the edge.
    -> DONE

在新的示例代码中,为第一个选项添加了一个名为push的标签,并在故事中作为一个变量存在。因为它本身是选项的一部分,所以每次在故事中重新访问该选项时,其值都会增加。这允许作者测试玩家是否多次选择了相同的选项。以下代码示例说明了这一点:

示例 8

You look at the rock in front of you.
-> rock
== rock
+ (push) Push the rock up the hill.
    -> rock
* {push >= 4} Push the rock over the edge.
    -> DONE

在最新的更改中,还添加了一个条件选项,使用大括号({})包围变量和值之间的比较。在新代码中,当故事开始时,读者可以选择推石头上山选项。当他们这样做时,该选项标签的值也会增加。一旦其值至少为4,第二个选项就会变得可用,读者可以将石头推到边缘,如下面的屏幕截图所示:

图 2.9 – 在示例 8 中选择四次将石头推上山的情形

图 2.9 – 在示例 8 中选择四次将石头推上山的情形

条件和标记选项也可以结合使用。然而,它们出现的顺序很重要。标记必须出现在条件选项之前。它们不能以其他顺序出现,如下面的代码示例所示:

示例 9

You look at the rock in front of you.
-> rock
== rock
+ (push) {push < 6} Push the rock up the hill.
    -> rock
* {push >= 6} Push the rock over the edge.
    -> DONE

在新的示例代码中,当故事开始时,读者只能看到一个选项。他们必须做出相同的选项六次,然后第一个选项才会被移除,第二个选项才会变得可用。一旦读者选择了这个选项,故事最终结束。

构建动态编织

粘性选择使得选项能够在通过转向相同节点创建的循环中保持。标记和条件选项允许在故事中满足某些条件后跟踪和显示某些选项。使用所有这些概念,动态编织成为可能,如下面的代码示例所示:

示例 10

You pause to double-check check the folder again. Yes, you have all the evidence here.
You nod at your partner and he enters the other room. You take a breath and open the door.
The suspect sits in front of you. As you take a seat, she turns to look at you.
-> interrogation
== interrogation
+ (knife) {knife < 1} [Ask about the knife]
    The suspect shakes their head. "I don't know nothing!"
    -> interrogation
+ (knife_again) {knife == 1 && knife_again < 1} [Ask about the     knife again.]
    You take a picture of the knife out of the folder and put       it down on the table without saying another word.
    -> interrogation
+ (knife_once_again) {knife_again == 1 && knife_once_again < 1} [Ask about the knife one more time.]
    "Yes. Fine. It's mine," the suspect replies and crosses       her arms. Looking at them, you notice the slight cuts on        the underside of her arm.
    -> interrogation
+ (cuts) {knife_once_again == 1 && cuts < 1} [Ask about the   cuts on her arm.]
    You point to the cuts on her arms. 
    She shrugs. "It was an accident."
    You frown and point at the knife.
    "It's my knife, yes," she says, looking away.
    -> interrogation
+ {cuts == 1} [Take out the picture of the gun next.]
    "This is not yours, though," you say, taking out the       picture.
    She does not look back.
    "He attacked you. And not for the first time," you say       and point to the older scars still visible. "You finally         had enough. You shot him."
    She still looks away, but you can see her shoulders slump.       She knows that you know. 
    -> DONE

在新代码中,使用复杂的标记和条件选项组合来跟踪在审问嫌疑人时的信息。做出一个选择会按顺序解锁下一个,因为流程会循环回相同的节点,随着信息的逐渐解锁,读者通过每次做出一个选择来学习更多。

注意

示例 10 中的一些条件选项使用两个和号 &&。这被称为 true,它检查下一个。如果两者都为真,整个组合就为真。

在下一章中,我们将介绍创建信息序列的更简单的方法,以及介绍将随机性引入创建文本和选项。与使用转向和节点不同,墨水提供了更简单的功能来完成相同的一般操作,并且每次使用节点时都明确地构建重复。

摘要

本章向您介绍了节点,故事的部分,以及转向,这些是它们之间移动的方式。我们探讨了使用 DONEEND 作为内置节点来结束流程(DONE)和完全停止故事(END)的用法。然后讨论了缝合,节点的子部分,以将故事分解成更多的部分。我们了解到可以使用 INCLUDE 关键字将故事分解成单独的文件,并将其作为同一项目的一部分 包含

节点可以转向自身。正如我们所看到的,这是创建循环结构的关键,在这种结构中,可以使用其他概念,如标记和条件选项,并将它们结合起来。标记允许我们创建变量来跟踪一个选项被显示的次数。然后,标记选项进一步发展到使用条件选项,测试在循环结构中使用时一个选项被选择的次数。最后,我们通过使用循环结构,其中每个选择都改变了标签的值并按顺序解锁每个选择,来制作动态编织。

在下一章中,我们将基于结和分叉的概念进行构建。通过导航到故事的不同部分,可以展示不同的文本,或者在多个循环中,一个 ink 概念,使得在故事中或因为多个循环,不同的选择成为可能。这允许 ink 对读者重新访问结和选项做出反应,以展示不同的内容。

问题

  1. 什么是结?

  2. DONEEND之间的区别是什么?

  3. 缝合是什么?

  4. 如何在 ink 中使用INCLUDE

  5. 标记选项和条件选项之间的区别是什么?

第三章:第三章:序列、循环和文本洗牌

本章介绍了替代方案的概念,这是引入额外文本的可编程方式,以及可以响应循环的高级代码。我们将依次介绍每种替代方案类型(序列循环洗牌),并探讨它们如何在 ink 中的循环结构中结合使用。接下来,我们将检查多行替代方案,这是基于创建它们的替代方案类型定义更复杂结构的功能。最后,我们将以嵌套替代方案结束本章,这是在彼此内部使用一个或多个替代方案。

在本章中,我们将涵盖以下主要主题:

  • 使用替代方案

  • 创建多行替代方案

  • 嵌套替代方案

技术要求

本章中使用的示例,在*.ink文件中,可以在 GitHub 上找到:github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language/tree/main/Chapter3

使用替代方案

第二章(B17597_02_Final_PG_ePub.xhtml#_idTextAnchor031)中,结点、转向和循环模式,使用开括号{和闭括号}表示条件选项的使用。在标签和条件之间,选项可以变得动态,并能够根据读者的选择在循环之间做出反应。然而,大括号不仅用于条件选项。在 ink 中,它们还表示任何代码的使用,而最常见的一种代码形式是使用|。根据使用的替代方案类型,可以产生不同的文本效果。

序列

第一个和默认的替代方案是一个序列。正如其名称可能暗示的那样,序列是一系列值。它们根据其名称,按顺序访问:

示例 1:

It was a {dark and stormy night|bright and shining day}.

示例 1中,使用了一个序列。它有两个元素,dark and stormy nightbright and shining day,它们之间有一个垂直条。首次运行时,序列将生成以下输出:

It was a dark and stormy night.

在序列中,任何第一个元素之后的元素只有在序列运行多次时才会显示。换句话说,包含多个元素的序列最好在循环结构中使用:

示例 2:

She looked out the window.
-> weather
== weather
+ What was the weather like?
    It was a {dark and stormy night|bright and shining day}.
    -> weather
* Ignore the weather.
    -> DONE

通过回到结点,weather,创建了一个循环。这允许序列的第二元素在第二个循环中显示:

Figure 3.1 – Screenshot of Inky showing both elements from Example 2

img/Figure_3.1_B17597.jpg

图 3.1 – Inky 屏幕截图,显示示例 2 中的两个元素

如果序列中包含新元素,则序列会继续。一旦序列到达其末尾,它将停止在最后一个元素上。在示例 2中,选择bright and shining day选项将再次显示:

Figure 3.2 – Screenshot of Inky showing the repeating element from Example 2

img/Figure_3.2_B17597.jpg

图 3.2 – Inky 屏幕截图显示示例 2 中的重复元素

重要提示

示例 2 遵循在 第二章**,节点、分支和循环模式 中建立的模式,有两个选择。第一个循环结构,第二个打破它。创建循环时始终使用选择,以避免创建无法结束的循环!

序列最好用于用户可能会耗尽一系列元素并最终停留在最后一个元素的情况。当需要重复序列时,使用不同的概念:循环

循环

如同序列,& 在集合的第一个元素之前。这告诉 ink 循环 元素,并在最后一个元素之后返回到第一个:

示例 3:

He flipped the calendar, looking at each month in turn.
-> calendar
== calendar
+ [Flip pages]
    He saw the month was {&January|February|March|April|May|      June|July|August|September|October|November|December}.
    -> calendar
* Put down calendar.
    -> DONE

示例 3 中,循环结构重新运行周期,遍历所有月份然后 循环 回到第一个元素:

图 3.3 – Inky 屏幕截图显示示例 3 循环中的所有元素

图 3.3 – Inky 屏幕截图显示示例 3 循环中的所有元素

所有替代方案都可以包含空元素。对于循环,任何空白元素都计入总数。可以创建一个循环,只有在经过一定次数的循环后才会显示元素:

示例 4:

He awoke to the sudden darkness. He tried to bend his elbows outward and his arms stopped against some sort of wall. Carefully bringing his arms up, he pressed his palms out and hit another surface inches from his face.
Desperate to figure out where he was, he remembered he still had his lighter in his pocket. Shifting the left side of his body against the wall, he reached the tips of his right hand into his back pocket and drew it out.
-> lighter
== lighter
+ [Try lighter]
    {&|||For a moment, there was light before the flame went out.}
    -> lighter

使用循环与空元素结合可以非常有效地创建一种情况,即读者必须多次采取行动后才能发生结果。在 示例 4 中,循环由多个空元素通过竖线分隔。最后一个元素,For a moment, there was light before the flame went out., 只有在读者选择 尝试更轻的火柴 四次之后才会显示:

图 3.4 – Inky 屏幕截图显示示例 4 使用空元素的情况

图 3.4 – Inky 屏幕截图显示示例 4 使用空元素的情况

洗牌

~ 出现在集合的第一个元素之前:

示例 5:

The lucky number for today is {~1|2|3|4|5|6|7|8|9|10}.

与序列和循环不同,洗牌不按顺序显示其元素。结合之前循环中显示的空元素,选择元素的几率始终与元素总数一致。这意味着要创建一个其中某个元素在 10 次运行中显示 1 次的洗牌,代码如下:

示例 6:

One out of ten runs of this shuffle will produce the number 10: {~|||||||||10}

示例 6 中,元素 10 有 10% 的出现概率。大多数运行(90%)不会看到它,为那些在故事内容叙述体验中遇到此事件的用户创造一个独特的时刻:

图 3.5 – Inky 屏幕截图显示示例 6 中不包含元素 10 的输出

图 3.5 – Inky 屏幕截图显示示例 6 中不包含元素 10 的输出

创建多行替代方案

序列、循环和洗牌可以使用一行代码来编写。然而,所有这些替代方案也可以使用它们的多行形式来编写。对于这些中的每一个,仍然使用大括号,但每个元素都在它自己的行上,前面有一个连字符,-

多行循环

要创建多行循环,使用关键字 cycle 并带有冒号,:,并且每个元素都在它自己的行上:

示例 7:

He flipped the calendar, looking at each month in turn.
-> calendar
== calendar
+ [Flip pages]
    He saw the month was <>{cycle:
    - January
    - February
    - March
    - April
    - May
    - June
    - July
    - August
    - September
    - October
    - November
    - December
    }<>.
    -> calendar
* Put down calendar.
    -> DONE

任何使用多行替代方案的情况也会引入一个新的问题。因为每个元素都在它自己的行上,所以每个元素也被 ink 视为一个。这意味着其输出会在行之间引入额外的间距。为了帮助解决这个问题,可以在多行替代方案和下一个文本内容之间使用粘合剂

提醒

第一章**,文本、流程、选择和交织,介绍了粘合剂的概念,即使用小于和大于符号。这个概念粘合了一行到前一行末尾。在多行替代方案中,使用粘合剂消除了其输出与下一行内容之间的额外空间。

多行序列

多行循环使用关键字 cycle,多行洗牌使用关键字 shuffle。然而,多行序列使用关键字 stopping

示例 8:

She looked out the window.
-> weather
== weather
+ What was the weather like?
    It was a <>{stopping:
    - dark and stormy night
    - bright and shining day
    }<>.
    -> weather
* Ignore the weather.
    -> DONE

多行序列也引入了它们与其单行形式之间的重要区别:可读性。多行替代方案中的每个元素都由一个新行分隔。在单行形式中,具有特别长的文本元素的序列很难区分其结束和下一个元素在替代方案中的开始:

{This is one really long line that keeps going and going just to make sure that it wraps to a new line.|This is more content as part of this second element.}

将代码拆分成新行,可以使代码更容易编辑和理解:

示例 9:

{stopping:
- This is one really long line that keeps going and going to make sure that it wraps to a new line.
- This is more content as part of this second element.
}

嵌套替代

序列、循环和洗牌都可以嵌套在彼此内部。当这种情况发生时,组合形式就是所谓的嵌套替代,其中一个替代方案作为另一个替代方案的元素。

结合循环和洗牌

在循环结构内部,循环可以在经过一定数量的循环后重复内容非常有用。当与洗牌结合使用时,可以从洗牌中随机选择内容,然后在更大的循环中重复。例如,为了在每次循环中生成一个新的随机数,使用单个元素和多次洗牌的循环可以通过一行代码产生这种效果:

示例 10:

Her hands were sweating, and her head hurt. She just needed to enter the correct digits into the controls and the vault would open. Once she got in and away with the treasure inside, she could be done with this job and leave this life behind. She had sworn there would only be one more job like this one job ago. This was truly the last one, she decided again.
She wiped her forehead and considered the controls again.
-> combination
== combination
What was the combination again?
+ [Was it {&{~1|2|3|4|5}-{~1|2|3|4|5}-{~1|2|3|4|5}}?]
    -> combination
* Give up on the controls. This was hopeless. She could not    remember the numbers.
    -> DONE

示例 10 使用了一个循环和三次洗牌。每次循环运行时,循环会重新运行。只有一个元素时,它会再次循环并重新运行洗牌,每次洗牌都会从一到五中随机选择一个数字。这会产生在每个循环中为每个循环生成一个新的三位数的效果:

图 3.6 – Inky 的截图,显示示例 10 中循环的随机三位数

图 3.6 – 显示示例 10 中循环的随机三位数的 Inky 截图

示例 10中的代码还演示了关于 ink 中可选方案工作方式的一个以前未明确说明的问题。可选方案元素通常具有文本内容,选项是从选择文本中创建的。这意味着可选方案可以与选择一起使用来生成动态选项。这也意味着转向可以是可选方案的元素。

例如,可以使用一组转向操作与洗牌结合,根据读者选择的选项随机地将读者移动到不同的位置:

示例 11:

They stood before the doorway at the end of the hallway. Without knowing where it would go, they reached out.
* [Open door]
    {~-> treasure|-> back_in_hallway}
== treasure
Yes! The room was full of treasure.
-> DONE
== back_in_hallway
As the door opened, there was a flash, and they blinked several times before realizing what had happened. They were back in front of the door. No!
-> DONE

当运行时,示例 11中转向和洗牌的组合将随机选择两个可能的分支之一。读者将被转向到treasureback_in_hallway节点。

洗牌洗牌

示例 10示例 11中,一种类型的可选方案与另一种类型结合使用。也可以嵌套相同类型的可选方案。洗牌可以嵌套在其他洗牌中,以生成高级组合结果。例如,可以通过定义可能的年份和事件来生成一个幻想王国的快速历史,然后在多行洗牌中使用单行洗牌来构建描述王国的句子:

示例 12:

It was the 
<> {shuffle:
- year {~1|2|3|4|5|6|7|8|9}{~1|2|3|4|5|6|7|8|9} of the New
    Era
- {~second|third|fourth|fifth|sixth|seventh} {~Year of the
    Frog|Year of the Snake}
   }
<> and our kingdom 
<> {shuffle:
- {~was doing well|was facing a crisis}
- {~was at war|was recovering from a war} with {~the
    giants|the elves|the humans}
   }
<>.

示例 12中,单行洗牌的使用创建了王国的所有小细节。然后,它们被用于更大的多行洗牌中,用于主要事件。它们基于不同的元素洗牌在一起构建历史:

图 3.7 – Inky 展示的 Example 12 的许多可能输出之一

图 3.7 – Inky 展示的 Example 12 的许多可能输出之一

可选方案及其多行形式可以通过使用序列和循环来显示新内容或更改旧内容,从而非常有用,以便检测和执行循环。具有在故事中引入随机性的能力,洗牌是一种简单的方法,可以在故事中生成动态文本,如示例 12所示。

当结合不同类型的可选方案,例如示例 10中使用的那些,这些嵌套的可选方案可以根据每种类型单独工作的方式生成复杂的内容。然而,下一章中未涉及并介绍的是保存由可选方案生成的内容并比较值的方法。与示例 10中使用的叙事一样,生成一个随机的三位数是有用的,但保存并记住它更好。第四章变量、列表和函数*介绍了如何保留由可选方案生成并在用户与 ink 交互时更改的值。

摘要

在本章中,我们探讨了看似简单的替代方案概念。在 ink 中,三种类型的替代方案是序列、循环和洗牌。每种都提供了访问其元素的不同方式。序列按顺序显示每个元素,直到最后一个。循环重复其元素,在遇到末尾后返回到第一个元素。洗牌每次运行时都会从其集合中选择一个随机元素,从而为故事引入随机性。

替代方案也可以用单行和多行形式表达。当以较长的多行形式书写时,替代方案使用一个关键字来表示其类型,并且每个元素都在单独的一行上。虽然对于作者来说更容易阅读,但我们回顾了如何注意将粘合剂融入其中,因为墨水如何解释故事中每一行的文本。

最后,我们了解到替代方案可以以嵌套形式结合。替代方案的一个元素可以是另一个替代方案。当它们一起使用时,这展示了例如循环和洗牌如何结合,每次运行时从多个洗牌中重新生成随机选择。我们还看到了如何使用替代方案中元素的文本与选择一起使用,甚至如何使转向成为替代方案的元素。

在下一章中,我们将看到如何在 ink 中创建和访问变量的值。

Q&A

  1. ink 中有哪三种类型的替代方案?

  2. 在单行形式中,元素之间使用什么特殊符号?

  3. 在集合中的第一个元素之前使用什么特殊符号来创建一个循环?

  4. 序列和循环之间的区别是什么?

  5. 洗牌有什么独特之处?

  6. 用于创建多行序列的关键字是什么?

第四章:第四章:变量、列表和函数

本章基于在第二章**、结、转向和循环模式*中引入的多个概念。在第一个主题中,我们将检查关键字 VAR 在墨水中与单个值的结合方式,以及它如何与 LIST 关键字结合使用。

在墨水中,我们可以将变量组合成一个称为列表的概念。我们还将检查如何创建和更改列表中的值。然后,我们将回顾它们在项目中最佳的使用情况以及可能更适合多个单独变量的情况。这次讨论将引导我们进入下一个主题,我们将探讨如何使用函数。

列表中的值可以通过称为函数的其他概念进行更改。在第三个主题中,我们将调用一些内置函数来处理不同的列表值。这将允许我们在列表上执行操作,例如确定条目数量或随机选择一个。使用函数将帮助我们为下一步做准备,即创建函数。

在最后一个主题中,我们将探讨如何在墨水中创建我们的函数。正如我们将看到的,函数允许我们定义小任务或一系列动作,我们可以通过调用创建的函数多次使用。我们将了解到,函数是墨水中的特殊形式。这意味着我们可以向函数以及结发送数据。然而,只有函数可以返回数据。

在本章中,我们将涵盖以下主要主题:

  • 使用 VAR 存储值

  • 使用 LIST

  • 调用函数

  • 创建新函数和调用结

技术要求

本章中使用的示例,在 *.ink 文件中,可以在 GitHub 上找到:github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language/tree/main/Chapter4

使用 VAR 存储值

第二章“结、转向和循环模式”中,变量作为在墨水循环结构编织中使用标记选项的一部分被引入。通过创建一个标签,一个选项可以记录它是否之前已经被展示过。这使得我们能够轻松地跟踪结内的循环数量。在墨水中,标记选项是存储和更改任何类型值的一般概念的一种形式。这种更一般的形式使用一个特殊的关键字:VAR

VAR 关键字创建了一个能够存储不同类型数据的变量。使用 VAR 关键字创建的变量可以存储数字(包括小数)、truefalse 值,甚至转向。使用 VAR 关键字创建的变量也是全局的:它们可以被任何属于整体项目的代码访问。

在 ink 中使用 VAR 关键字的变量名遵循结和针的相同规则:

  • 它们可以包含数字。

  • 它们可以包含大写和小写字母。

  • 允许使用的唯一特殊符号是下划线。

类似于结和针的命名规范,变量名中的单词之间通常使用下划线分隔:

示例 1 (Example1.ink):

VAR reader_name = "Dan"

变量的值通过称为 = 的操作来赋予,将变量赋值为同一行上的符号之后的值。在变量名、等号(=)和分配给变量的值之间包含一个空格是常见的。

使用 VAR 关键字和为变量分配初始值时,必须始终遵循的一个明确规则是:无论值是什么,它都必须是静态的。在 ink 中,这意味着任何变量的第一次赋值不能是其他现有值的组合或执行数学运算的代码的结果。

然而,一旦创建,变量的值可以更改,但初始赋值必须始终存在,并且不能是任何计算的任何结果。

在这个主题中,我们将学习如何显示和更改变量值。因为替代方案(在第 第三章序列、循环和文本洗牌)产生值,我们还将探讨如何保存它们产生的值,并将该值作为附加代码的一部分使用。

显示变量

ink 中使用开括号 { 和闭括号 } 花括号表示代码的使用。当与变量名结合使用时,ink 将将变量的值替换为周围文本的一部分。这允许作者将变量作为文本的一部分使用,并在 ink 的最终输出中将变量的名称与其值进行替换:

示例 2 (Example2.ink):

VAR reader_name = "Dan"
The name of the reader is {reader_name}.

示例 2 中的代码运行时,ink 创建一个名为 reader_name 的变量。接下来,它将变量设置为 "Dan" 的值。当它遇到文本时,ink 理解花括号是代码,并将变量的名称替换为其值:

图 4.1 – 示例 2 的 ink 输出截图

图 4.1 – 示例 2 的 ink 输出截图

使用花括号表示 ink 中使用任何代码。这意味着数学运算也可以在变量中的花括号内执行。ink 将将结果值作为最终输出的一部分替换,如下例所示:

示例 3 (Example3.ink):

VAR first_variable = 2
VAR second_variable = 2
Adding the values of the two variables together produces: 
  {first_variable + second_variable}.

示例 3 中有两个变量。每个变量都持有不同的数值。当 ink 遇到花括号的使用时,它将每个变量的值替换为其名称。接下来,因为花括号还包含加号,ink 将两个数字相加:

图 4.2 – 示例 3 的 ink 输出截图

图 4.2 – 示例 3 的 ink 输出截图

根据涉及的数据类型,数学运算可能不会总是根据在其他编程和脚本语言中的经验产生预期的输出。例如,加法(使用加号 +)、减法(使用连字符 -)、乘法(使用星号 *)和除法(使用正斜杠 /)都将作用于数值:

示例 4 (Example4.ink):

VAR number_two = 2
2 * 2 = {number_two * 2}
2 + 3 = {number_two + 3}
6 / 2 = {6 / number_two}
9 - 2 = {9 - number_two}

使用数值和字符串值进行数学运算会产生错误。使用数学符号与字符串值的唯一有效方式是使用加号 (+)。这执行的是 连接 操作:当一个数值或字符串值被 添加 到现有的字符串值时,它会产生一个新的字符串值,如下面的示例所示:

示例 5 (Example5.ink):

VAR example_string = "Hi"
Perform concatenation: {example_string + 3}

当运行 示例 5 中的代码时,ink 会创建一个具有字符串值的变量。然而,当它遇到文本和大括号时,它不会执行数学运算。相反,它 连接 example_string 变量的值和数字 3。这在其输出中产生了这两个值的组合:

图 4.3 – ink 为示例 5 生成的输出截图

图 4.3 – ink 为示例 5 生成的输出截图

让我们现在转到下一部分,了解如何更新变量。

更新变量

变量的值可以改变。一旦在 ink 中使用 VAR 关键字创建了一个变量,就可以在任何代码点访问它并更改其值。然而,虽然 ink 理解变量的初始赋值必须与文本分开,但在更改变量值时,我们必须使用一个特殊符号:波浪号 (~)。例如,我们可以使用 VAR 关键字创建一个变量,然后在同一代码的稍后部分更改其值:

示例 6 (Example6.ink):

VAR reader_name = "Dan"
The reader's name is {reader_name}.
~ reader_name = "Jesse"
The reader's name is now {reader_name}.

如果一行代码以波浪号 (~) 开头,这会让 ink 知道该行将发生某种类型的代码。例如,当为变量分配初始值时,ink 理解波浪号 (~) 后将跟随与代码相关的某些内容。

我们可以使用 VAR 关键字创建变量,并使用以波浪号 (~) 开头的代码行来更新它们。然而,正如我们在 第三章 序列、循环和文本洗牌 中所看到的,替代方案允许我们从一组值中生成一个值。正如我们将在下一节中看到的,我们可以将生成的值保存在变量中,并在同一代码的后续部分使用它。

存储替代方案当前值

替代方案第三章 序列、循环和文本洗牌 中被引入。它们用于根据循环结构中它们被访问的次数生成 替代 文本内容。因为替代方案生成文本,它们的输出也可以保存在变量中:

示例 7 (Example7.ink):

VAR day_of_week = ""
~ day_of_week = "{~Monday|Tuesday|Wednesday|Thursday
    |Friday|Saturday|Sunday}"
-> calendar
== calendar
Today is {day_of_week}!
-> DONE

而不是需要重新运行替代方案来生成新的文本,洗牌可以将其输出保存以供将来使用。这允许将值纳入其他代码,而无需重新创建替代方案。

替代方案在运行时生成内容。这意味着它们不能用作 ink 中变量的初始赋值的一部分。原因在于 ink 在运行任何替代方案之前处理变量的创建。任何替代方案的生成值在变量最初创建时并不存在。因此,一个常见的模式是创建一个具有初始值的变量,然后在代码的后面用替代方案的生成输出覆盖这个值。

示例 7中,引号包围了使用洗牌替代方案来创建字符串值的使用。当运行时,洗牌将生成一个值,然后根据其周围的引号成为字符串。作为一个字符串值,它将能够与带有波浪号(~)的赋值行一起使用。

将替代方案的输出保存到墨迹中通常需要至少两行代码。第一行用于使用VAR关键字创建一个变量并赋予其初始值,而第二行则在代码运行时将变量的值重新赋值为替代方案生成的结果。正如在使用 VAR 存储值主题的介绍中所解释的,使用VAR关键字的显式规则是变量创建时初始值必须存在。替代方案产生的输出被认为是动态的,使用VAR关键字创建的变量的初始值必须是静态的。

在这个主题中,我们处理了单一值。我们首先使用VAR关键字创建新变量,然后学习了如何使用以波浪号(~)开头的行来更新它们的值。我们还探讨了如何保存替代方案的输出,但变量必须被设置为初始值,然后更新为替代方案产生的动态输出。在下一个主题中,我们将基于变量的使用,并使用一个新的关键字LIST创建它们的集合。

处理 LIST

每次使用VAR关键字都创建一个单一值。在许多项目中,一些单一值就足以在运行时跟踪所需的一切。然而,在某些情况下,可能需要一组值。对于这些情况,ink 有一个特殊的名为LIST的关键字,它可以创建一个列表的可能值。

列表中的值可以被视为其变量的可能状态。例如,对于名为days_of_weekLIST,可能值可能是每周的 7 天。这些可以用LIST本身定义,然后按需分配,而不是需要为每周的每一天使用字符串值。

在 ink 中,列表在项目上下文中定义了一个新的值集合。一旦创建,列表的值可以作为其他使用VAR关键字创建的变量的可能值。

然而,尽管在创建变量的新可能值方面功能强大,但创建的值有一些限制,并且通常需要额外的功能来执行 ink 中其他数据类型可用的某些常见操作。(LIST 函数将在本章后面的 使用 LIST 函数 部分介绍。)

在这个主题中,我们将从创建一个值列表开始。我们将探讨如何使用 LIST 关键字创建这个集合。然后,我们将通过遵循我们学习到的处理单个值的相同模式来更改集合中的值。

创建 LIST

可以使用 LIST 关键字创建一个新的列表。在以 LIST 关键字开头的行上,列表的名称后面跟着等号(=),其值由逗号分隔。列表的名称,就像 ink 中的其他变量一样,只能包含数字、字母和下划线字符。它不能包含其他特殊符号或空格。

与使用 VAR 关键字不同,当值分配给列表时,空格会被忽略,包括一个值和下一个值之间额外的空行。使用 VAR 关键字创建的变量必须在单行上定义。列表的值可以分布在多行上:

示例 8(Example8.ink):

LIST days_of_week = 
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday
VAR day = Monday
Today is {day}.

示例 8 中,创建了一个包含七个可能值的列表。接下来,将其中的一个值,Monday,分配给了使用 VAR 关键字创建的变量。最后,代码的最后一行显示了 day 的值:

图 4.4 – 示例 8 的 ink 输出截图

图 4.4 – 示例 8 的 ink 输出截图

与使用 VAR 关键字创建的变量一样,一旦创建,列表的值也可以更新。这遵循我们在 更新变量 部分介绍的模式。要更新列表或其值,正如我们将在下一节中学习的,需要以波浪线(~)开头的行。

更新 LIST 值

虽然,如 示例 8 所示,值似乎可以显示,但创建的输出是值的名称,Monday,而不是字符串,"Monday"。关于项目的输出,这是使用 VAR 关键字和作为 LIST 关键字集合一部分的变量之间的小但重要的区别:只有 LIST 值可以添加到列表中。要向现有列表添加新值,它必须是由自身或其他列表创建的:

示例 9(Example9.ink):

LIST all_pets = Cats, Dogs, Fish
LIST current_pets = Cats, Dogs
~ current_pets = current_pets + Fish

示例 9中,Fish值可以被添加到current_pets中,因为它作为另一个列表all_pets的一部分被创建。这说明了使用列表中值的一个主要问题:虽然它们对于向项目中引入新的可能值非常有用,但它们必须在可以访问之前被定义。任何新的列表都依赖于之前定义或在其赋值内创建的值。然而,在 ink 中,可以将值更改为另一种数据类型:

示例 10 (Example10.ink):

LIST standing_with_family_members =
father = 0,
mother = 1,
sister = 2,
brother = 0

示例 10中,standing_with_family_members列表的每个值也被分配了一个数字。在 ink 中允许这样做,并且可以是一种创建与项目中的数值相关联的特定列表值的有用方式。然而,访问这些数字需要理解 ink 的另一个概念:函数

调用函数

函数是大多数编程语言的基础部分。在 ink 中,函数是一组代码,可以接受由逗号分隔的输入,可能产生输出,并且可以通过称为调用的操作来访问。

函数通过使用其名称,然后打开(()和关闭())括号来调用。在 ink 中调用函数的操作会暂时将故事的流程移动到函数的代码中,并在代码完成后返回。

注意

函数只能在 ink 中的代码内调用。这意味着它们要么出现在开闭花括号内,要么出现在以波浪号(~)开头的行上,作为变量重新赋值的组成部分。

在这个主题中,我们将首先回顾 ink 中内置的一些函数以及它们如何帮助我们进行常见操作。接下来,我们将查看专门设计用于与使用LIST关键字创建的值一起工作的函数。这些函数在列表上执行常见操作,例如告诉我们列表中的条目数量或从其集合中随机选择一个条目。

常见数学函数

ink 中最常用的函数之一是RANDOM()。它接受一个最小值和一个最大整数值。然后它从这个指定的范围内选择一个随机数字。

对于许多角色扮演游戏,一个常见的需求是在一定范围内的数字,例如 1 到 4 或 1 到 20。RANDOM()函数允许我们设置一个范围,然后查看结果:

示例 11 (Example11.ink):

An example of a dice roll of 1-to-20 is {RANDOM(1,20)}.

当运行示例 11代码时,ink 将遇到一组花括号。然后它会看到具有最小值 1 和最大值 20 的RANDOM()函数。每次运行时,都会选择这个范围内的不同数字:

图 4.5 – ink 输出示例 11 的截图

图 4.5 – ink 输出示例 11 的截图

ink 还具有用于在不同类型数字之间进行转换的函数。INT() 函数将十进制数字转换为整数(整数)数字,而 FLOAT() 函数将整数转换为十进制(浮点)数字。每个函数都接受一个数字并产生不同类型的数字输出:

示例 12 (Example12.ink):

VAR example_decimal = 3.14
VAR example_integer = 5
Convert a decimal into an integer: {INT(example_decimal)}.
Convert an integer into a decimal: {FLOAT(example_integer +
  1.3)}.

函数产生的值可以保存在变量中。这允许,例如,使用 RANDOM() 函数及其值,该值已作为变量的一部分保存:

示例 13 (Example13.ink):

A common table-top role-playing game combination is 2d6 where two dice rolls of 1-to-6 are rolled, and their values combined.
VAR dice_one = 0
~ dice_one = RANDOM(1,6)
VAR dice_two = 0
~ dice_two = RANDOM(1,6)
The combined total of 2d6 is {dice_one + dice_two}.

示例 13 代码包含两个变量和两次对同一函数的使用。正如在 使用 VAR 存储值 主题开头所提到的,变量必须以静态值开头。在 示例 13 代码中,每个变量最初被赋予 0 的值。它们立即被赋予由 RANDOM() 函数生成的值。然而,作为使用 VAR 关键字创建的变量的显式规则的一部分,它们必须在重新分配由 RANDOM() 等函数生成的动态变量之前被设置为静态值。

当运行时,示例 13 代码创建了必要的变量,并从 RANDOM() 函数的调用中重新分配它们的值,最小值为 1,最大值为 6。当 ink 遇到文本和大括号的使用时,它会将这两个值相加:

图 4.6 – 示例 13 的 ink 输出截图

图 4.6 – 示例 13 的 ink 输出截图

图 4.6 – 示例 13 的 ink 输出截图

提示

与使用洗牌替代方案和 VAR 关键字一样,RANDOM() 函数的输出不能用作变量的初始值。它必须首先创建,然后才能重新分配 RANDOM() 生成的值。

正如我们所见,ink 中有多个内置函数用于处理单个值。这也适用于使用 LIST 关键字创建的值。在下一节中,我们将回顾一些专门为列表及其值设计的函数。

使用 LIST 函数

虽然有针对单个值设计的函数,但 ink 中的大多数内置函数都是与列表一起使用的。这些函数都以 LIST_ 前缀开头,并且它们执行的操作或访问的内容作为第二个单词。例如,要计算列表中包含的值的数量,可以使用 LIST_COUNT() 函数:

示例 14 (Example14.ink):

LIST days_of_week = 
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday
The total days are {LIST_COUNT(days_of_week)}.

当运行时,示例 14 代码创建了一个包含七个值的列表。在大括号中是对 LIST_COUNT() 函数的调用。然后,该函数将 days_of_week 列表作为参数传递。根据列表赋值中使用的行,默认假设输出将是 7,基于列表中的天数。然而,情况并非如此。它的输出是 0

图 4.7 – 示例 14 的 ink 输出截图

图 4.7 – ink 为示例 14 生成的截图

示例 14产生的输出显示了使用列表值工作的一个隐藏方面。技术上,用于创建列表的所有值都称为truefalse,并且默认情况下,所有值都设置为false

LIST_COUNT()函数计数列表中true值的数量。在示例 14中,没有。该函数产生的计数是正确的。要将值从其默认的false更改为true,需要将其括在开括号(()和闭括号())内:

示例 15 (Example15.ink):

LIST days_of_week = 
(Monday),
(Tuesday),
(Wednesday),
(Thursday),
(Friday),
(Saturday),
(Sunday)
The total days are {LIST_COUNT(days_of_week)}.

示例 15中,输出包括数字7。这是正确的。现在,示例 14列表中的每个值现在都包含在其自己的括号内,每个值从false变为true

对于需要从列表中获取每个值的情况,无论其是true还是falseLIST_ALL()函数都会返回所有值:

示例 16 (Example16.ink):

LIST days_of_week = 
(Monday),
(Tuesday),
(Wednesday),
(Thursday),
(Friday),
(Saturday),
(Sunday)
The days of the week are: {LIST_ALL(days_of_week)}.

示例 16中,使用LIST_ALL()函数返回当前days_of_week列表的所有值:

Figure 4.8 – Screenshot of ink's output for Example 16

Figure 4.8 – Screenshot of ink's output for Example 16

图 4.8 – ink 为示例 16 生成的截图

LIST_RANDOM()函数返回一个关于列表中true值总数的随机条目,如下例所示:

示例 17 (Example17.ink):

LIST days_of_week = 
(Monday),
(Tuesday),
(Wednesday),
Thursday,
Friday,
Saturday,
Sunday
A random day of the week is: {LIST_RANDOM(days_of_week)}.

示例 17中,只有MondayTuesdayWednesday值被设置为true。由于days_of_week的其他值默认设置为false,因此不能通过LIST_RANDOM()函数访问。

返回到示例 10的代码,LIST_VALUE()函数可以用来访问作为列表一部分分配给任何值的任何数据:

示例 18 (Example18.ink):

LIST standing_with_family_members =
father = 0,
mother = 1,
sister = 2,
brother = 0
The value of sister is {LIST_VALUE(sister)}.

在这个改进版本的示例 10,即示例 18中,可以使用LIST_VALUE()函数来访问分配给sister值的已分配数据。

虽然墨水有许多用于执行不同选项的功能,无论是数学上还是与列表值相关,但它还向作者提供了创建自定义功能的能力。在下一个主题中,我们将回顾如何创建和调用函数。

创建新函数和调用节点

调用函数主题介绍了接受输入、可能产生输出以及如何调用 ink 内置函数。

在 ink 中也可以使用function关键字创建新函数。在 ink 中创建的任何新函数都可以像其他函数一样使用,并且它们通常是创建可用于整个项目或多次使用而无需再次编写相同代码的独立代码行的有用方式。

在这个主题中,我们将探讨如何使用function关键字创建新的函数。我们将学习它们如何被调用,执行小任务,甚至可能返回数据。在第二章**,“结、转向和循环模式”,我们最初讨论了结。故事的不同部分由名称定义。在 ink 中,函数,正如我们将学习的,是特殊的结类型。这种关系意味着结也可以被调用传递数据。

函数使用至少两个等号(=)、function关键字、函数名称,然后是括号(()),括号围绕其输入(如果有)。函数的名称遵循与变量和结相同的规则:它们可以包含数字、字母和下划线字符。它们不能包含其他特殊符号或空格。

与变量一样,函数在 ink 中也是全局的。一旦创建,它们就可以被项目中的任何其他代码访问。由于变量和函数都是全局的,因此常见的模式是设计一个改变单个变量的函数。这允许作者定义在调用函数时发生的行为,例如增加或减少其当前值:

示例 19(Example19.ink):

VAR money = 30
VAR apples = 0
VAR oranges = 0
You approach the marketplace and consider what is on sale. 
-> market
== market
You have {money} gold.
You have purchased {apples} apples.
You have purchased {oranges} oranges.
+ {money > 10} [Buy Apple for 10 gold]
    ~ decreaseMoney(10)
    ~ increaseApples()
    -> market
+ {money > 15} [Buy Oranges for 15 gold]
    ~ decreaseMoney(15)
    ~ increaseOranges()
    -> market
* [Leave market]
    -> DONE
== function decreaseMoney(amount)
~ money = money - amount
== function increaseApples()
~ apples = apples + 1
== function increaseOranges()
~ oranges = oranges + 1

示例 19使用了三个不同的函数。第一个,decreaseMoney(),接受一个名为amount的值。这是一个参数的例子。在创建函数时,可以在其开括号和闭括号内定义不同的变量。这些被称为其参数,它们影响其执行计算或处理的方式。

当调用函数时,传递给它的数据被称为其decreaseMoney(),它有一个参数并接收一个单一的参数,以及两个不接受参数的函数,increaseApples()increaseOranges()

increaseApples()increaseOranges()函数的位置也符合 ink 中的一种常见模式,即在代码底部找到用于调整变量值的函数。因为它们都是全局的,这意味着可以从项目的任何地方访问它们,所以函数可以在项目的任何地方定义。

然而,函数,就像它们的姐妹概念结(knots)一样,定义自己为从开始到下一个结或函数之间的所有线条。将它们放在文件底部可以防止代码可能被混淆或被认为是另一个结或函数的一部分的问题。

函数不是唯一能够定义参数和接受参数的概念。在 ink 中,也可以像函数一样被调用。这是因为函数是特殊的结类型,可以返回数据。这也标志着它们之间的区别。结可以接受数据,但只有函数可以返回数据。然而,以这种方式使用结可以让我们轻松跟踪循环结构中的值:

示例 20(Example20.ink):

-> time_machine(RANDOM(20,80))
== time_machine(loop)
The large machine looms over everything in the room. With flashing lights, odd wires running between parts, and a presence all its own, it seems to be almost a living, pulsating thing as the scientist runs between sections parts and adjusts various parts.
"I'm so close!" he shouts as he turns a knob and then pulls down a lever. "I just need more time to figure out how to control the loops!"
You regard him and the machine skeptically.
"If you could, just press that last button and everything should be all set for the first demonstration of my time machine! I'm so glad the newspaper sent you to cover this event," he says, adjusting more settings on the grand machine in front of you.
You pause to try to understand the blinking lights as he yells again. "Press the button for me! I just need to make some last-minute changes over here."
On the panel in front of you is a large, green button. You consider it and the scientist rushing around across the room.
+ [Press button]
    ~ loop = loop + 1
    There is a flash of light and the readings on the 
      machine show a message: "This is loop {loop}."
    -> time_machine(loop)

示例 20中,存在一个变量,它是作为time_machine节点的参数创建的。在循环开始之前,使用RANDOM()函数从2080的范围内选择一个值。这个值在第一次循环中传递给节点。每当玩家选择loop值增加一个,并将当前值传递给time_machine节点。在未来的任何循环中,loop变量增加一个,并将它的当前值发送到下一个循环。

示例 20中的代码还展示了如何在不使用VAR关键字的情况下使用变量。在节点内部,loop变量作为一个参数存在。这意味着它作为一个变量存在,但仅限于time_machine节点内部。以这种方式使用时,loop变量将不是全局的。作为time_machine节点的一部分,loop变量不能在其代码之外使用。

函数是墨水中的一个强大概念。然而,与使用节点相比,它们确实有两个主要的限制。第一个限制是函数不能使用任何类型的选项。函数不能分支故事,并且在完成时必须返回到它们被调用的位置。第二个限制是函数不能偏离到故事的另一个部分。就像第一个限制一样,函数应该只执行一个小任务或更改一个值。

将节点调用得像函数一样非常有用,对于许多项目来说都是如此。然而,与函数不同,节点不能返回值。正如示例 20所示,可以使用节点来完成一些与墨水中的函数相同的目的,但不是所有。作者必须考虑使用函数或节点来完成任务或展示信息哪种方式更好。如果目标是处理数据并返回值,函数是最好的选择。如果需要传递数据、提供选项或故事以某种方式偏离,节点是组织代码和数据更好的方式。

摘要

在本章中,我们更多地了解了变量是如何工作的,以及如何使用VAR关键字来创建它们。使用多种类型的数据,必须使用静态值来创建变量。然后可以通过使用以波浪号(~)开头的行进行赋值操作来更改它们。

在第二个主题中,对于需要多个值的情况,我们看到了可以使用LIST关键字。这个关键字允许我们创建其他变量可以使用的值,但也带来了限制,即只有使用LIST创建的值才能与列表一起使用。我们还研究了列表的所有值都是一个布尔集合的一部分,并且在创建时具有truefalse值。

接下来,在第三个主题中,我们研究了函数在墨水中的工作方式。通过几个内置函数,我们可以创建随机数或在不同类型的数字之间进行转换。使用LIST值,我们通过检查如何将列表的值从创建时的true更改为false,比较了LIST_COUNT()LIST_ALL()的结果。

最后,在最后一个主题中,我们使用function关键字编写了一些函数来执行简单的任务,例如调整变量的值。因为两个变量都是使用VAR关键字创建的,而函数是全局的,所以我们看到一种常见的模式是使用函数来改变变量的值。作为这个主题的一部分,我们还回顾了节点,并了解到函数是特殊的节点类型。这允许它们都通过参数接收数据,尽管只有函数可以返回数据。

正如我们将在接下来的章节中看到,结合 ink 和 Unity,理解值在 ink 中的存储和访问方式对于创建统一的项目至关重要。在我们能够在 Unity 中处理代码之前,我们必须了解 ink 如何与使用VAR关键字和LIST关键字创建的不同值一起工作。通过理解变量和函数之间的关系,我们可以开始编写 ink 代码,并且可以在 Unity 中与 C#代码一起运行,这要晚得多。

在下一章,第五章**, 隧道和线程,我们将探讨 ink 中的最后两个主要概念:隧道线程。利用上一章介绍的大多数概念,我们将使用隧道在 ink 中创建具有非常少代码的高级结构。使用线程,我们将把数字故事分成更多部分,并让 ink 为我们这个读者将一切组合起来,当我们从一个节点被引导到另一个节点时。这将创建一个复杂的叙事体验,基于理解和管理故事段落之间的故事流程。

问题

  1. 变量获得值的操作叫什么?

  2. 当通过“添加”两个其他字符串或一个字符串和一个数字来创建字符串时,这个操作叫什么?

  3. 在 ink 中,波浪号(~)是如何与变量和代码一起使用的?

  4. 列表值的类型是什么?

  5. 什么是作为函数或节点的一部分创建并在其括号内定义的变量的技术术语?

第五章:第五章:隧道和线程

本章从隧道的概念开始。通过多个转向和至少两个结(请参阅第二章,“结、转向和循环模式”)创建,隧道提供了一种比上一章中讨论的更快的创建复杂结构的方法。在此之后,我们将继续回顾线程,这是另一种使用转向动态连接墨水项目多个部分的方法。最后,我们将探讨结合隧道和线程,根据墨水在故事中理解结和转向的简单规则,创建更复杂的结构。

在本章中,我们将涵盖以下主要主题:

  • 转向到转向

  • 拉动线程

  • 结合隧道和线程

在本章中,我们将探讨使用隧道和线程创建更复杂项目的各种方法。我们已经探讨了多个级别的选择及其结果,以创建一个细分的故事。我们不会转向一个接一个的结或针,而是学习如何将隧道作为一系列转向整合,然后再返回到它们原来的位置。我们还将探讨如何通过编织将结轻松地组合在一起。

技术要求

本章中使用的示例,在*.ink文件中,可以在 GitHub 上找到:github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language/tree/main/Chapter5

转向到转向

第二章,“结、转向和循环模式”,转向的概念与被称为结的故事部分一起引入。使用转向和结,创建了循环结构,并揭示了其他墨水概念作为替代方案(请参阅第三章,“序列、循环和文本洗牌”)。在第四章,“变量、列表和函数”中,介绍了函数的使用和向结传递值。本节基于这些概念,通过解释如何使用转向来创建更高级的故事来构建。

第二章,“结、转向和循环模式”中,转向按照以下模式出现:

示例 1:

For the reader, <>
-> next_part
== next_part
this appears as one line.
-> DONE

图 5.1 – 示例 1 的 Inky 输出

图 5.1 – 示例 1 的 Inky 输出

转向也可以多次使用。在 ink 中,这被称为隧道的概念。流程将移动到一个结,然后返回到其原始位置。从读者的角度来看,流程隧道从一个部分到另一个部分。隧道是 ink 中循环结构常见时的一个极其有用的概念。

在这个主题中,我们将回顾如何制作和使用隧道。不需要在结或缝合中指定每个转向的位置,隧道允许作者将故事的流程通过一系列章节移动,并在隧道最终结束时再次返回。

制作隧道

通过使用转向符号->、结或缝合的名称,然后另一个转向符号->,在墨水中创建隧道。这会告诉墨水,流程将移动到结,然后再返回。在目标结中,然后使用两个转向符号一起:->->。这创建了流程移动到结然后再返回的隧道效应

示例 2:

For the reader, <>
-> next_part ->
<> as one line
== next_part
this appears
->->

如果你忘记了墨水中的流程是如何工作的,示例 2可能会显得令人困惑。转向符号将流程移动到故事中的目的地。在示例 2中,流程从For the reader, <>的文本开始。然后,这被转向到next_part结。隧道的开始以-> next_part ->的代码开始,继续到next_part结,然后使用两个转向符号->->返回。对于只看到输出的读者来说,它将看起来是一个完整的句子:

![Figure 5.2 – Inky 的 Example 2 输出Figure 5.2_B17597.jpg

图 5.2 – Inky 的 Example 2 输出

提醒

第一章,文本,流程,选择和编织介绍了胶水的概念以及使用小于和大于符号的组合。这个概念粘合了一行到前一行。在示例 2中,胶水用于隧道开始之前和结束之后,以从故事中的结创建完整的句子。

隧道可以连接故事中的任何两个点。在示例 2中,隧道开始,移动到一个结,然后返回。隧道可能还有另一种用途,即为玩家创建一系列故事事件来查看。你可以通过从一个结到下一个结创建隧道直到故事结束来实现这一点:

示例 3:

You lift the body onto your back and then carry it over to the edge of the hole before dropping it again. You watch it hit the ground with a pleasant thump. It is dirty work, gravedigging is (you laugh at your own joke as you brush some dirt off your hands and onto your already dirty pants), but it pays the bills.
-> past -> present -> future -> DONE
== past
You did not want to be a gravedigger at first. Who does? No, you stumbled into it as many people do. You needed the money and dead people were dead as far as you were concerned. Dig a hole, put the body in, cover the hole. Easy work. Easy money.
->->
== present
You shake your head and then kick the body so that it plops into the grave. Another sound you did not expect to like when you started so many years ago, but you take little joys where you can. Life is funny that way.
->->
== future
"There's no future in the dead," your wife had said. But she is dead now, too. And what did she know? Other than dirt! (You laugh at another of your jokes.)
You pick up the shovel.
One load of dirt after another.
It is a living. Or a dying! (You laugh again to yourself as you continue.)
->->

示例 3中,三个不同的结(即过去现在未来)都是一条长隧道的部分。第一个连接到过去结,然后过去结连接到现在结,现在结连接到未来结,最后,未来结连接到完成结以结束流程和故事。在这些情况下,使用两个转向符号->->都指回隧道开始的地方,然后再继续到更长的系列中的下一个结。与示例 2及其完整的句子一样,示例 3的结果是读者通过章节的单个流程来创造一个完整的叙事体验。

隧道到隧道

示例 3 指出了隧道的伟大用途:它们可以连接到其他隧道!在墨迹中,可以进入隧道中的隧道。虽然像 示例 3 中使用的线性模式很常见,但高级模式会重用隧道作为更大、循环结构的一部分。因为使用两个转向的结总是会返回到起始位置,所以可以使用结来执行小计算或检查值,然后再继续重复的模式:

示例 4:

VAR playful = 0
VAR anger = 0
On your daily walk, you decide to sit for a few minutes on a nearby bench. You close your eyes to take in the evening sun.
Suddenly, you hear a small sound and look down. A kitten is circling your legs.
-> kitten
== kitten
-> check_kitten ->
+ [Scratch the kitten on its head]
    You pet the kitten on its head.
    -> scratch_head -> kitten
+ [Scratch the kitten on its side]
    You pet the kitten on its side.
    -> scratch_side -> kitten
== scratch_head
~ playful = playful + 1
->->
== scratch_side
~ anger = anger + 1
->->
== check_kitten
{anger >= 2: The kitten seems angry and walks away. -> DONE}
{playful >= 2: One moment, you were scratching the kitten and the next your hand has some small cuts on it. You decide to leave the kitten alone. -> DONE}
->->

示例 4 展示了一个使用结、变量和隧道的更复杂模式。对于 kitten 结的每一圈,都会为 check_kitten 结创建一个隧道,然后再次返回到 kitten。在 check_kitten 结中,进行了两次检查。第一次检查是确保 anger 变量的值大于或等于 2。如果是,则显示句子,并将故事转向 DONE。第二次检查是确保 playful 变量的值大于或等于 2。如果这个第二次检查为真,则显示不同的句子,并将故事转向 DONE。在由两个粘性选择创建的编织中,每个选项,无论是 scratch_head 还是 scratch_side,都会增加与玩家动作相关的结的变量值。

示例 4 使用多个隧道创建了一个复杂模式。然而,墨迹中还有一个概念可以使相同的代码更容易理解:线程。正如我们将在下一节中讨论的,线程允许我们轻松地拉入结,而无需首先转向它们。

拉动线程

转向被引入为 指向 目的地。要创建隧道,将破折号和大于符号组合在一起,->,位于结或针的名称两侧。然而,转向也可以 指向 内部。当使用小于符号和破折号创建转向时,<-,它变成了一个称为 线程 的不同概念。而不是将流程移动到目的地,墨迹 线程化 目的地文本或代码到另一个位置。

在本节中,我们将使用线程将更复杂的编织简化为更简单的结构。而不是多级选择及其文本结果,我们将使用线程以更有效的方式实现相同的结果。

制作线程

通常,线程被认为是转向的逆。而不是流程移动到故事的部分,故事的部分移动到流程的当前位置。回到 示例 4 的代码,线程可以在多个地方使用以实现相同的结果:

示例 5:

VAR playful = 0
VAR anger = 0
On your daily walk, you decide to sit for a few minutes on a nearby bench. You close your eyes to take in the evening sun.
Suddenly, you hear a small sound and look down. A kitten is circling your legs.
-> kitten
== kitten
<- check_kitten
+ [Scratch the kitten on its head]
    You pet the kitten on its head.
    <- scratch head 
    -> kitten
+ [Scratch kitten on its side]
    You pet the kitten on its side.
    <- scratch_side 
    -> kitten
== scratch_head
~ playful = playful + 1
== scratch_side
~ anger = anger + 1
== check_kitten
{anger >= 2: The kitten seems angry and walks away. -> DONE}
{playful >= 2: One moment, you were scratching the kitten and the next your hand has some small cuts on it. You decide to leave the kitten alone. -> DONE}

示例 5 中,使用了线程代替了 示例 4 中的先前隧道。现在 scratch_headscratch_side 结被 线程化kitten 结的代码中。对于 check_kitten 也是如此。而不是创建多个隧道,通常使用线程将越来越复杂的结构折叠成可以 线程化 在一起的段落。

警告

有时,使用复杂的隧道和线程结构可能会让 Inky 迷惑。在使用这些更高级的概念时,总是要仔细检查所有代码!

使用多个线程

每个线程的使用都必须单独一行。它们不能合并的原因是墨水会将故事的部分移动到当前流程的位置。第二个线程不能将其内容移动到上一个位置。它不再存在了!然而,线程,就像改道一样,也可以是集合或替代中的元素。就像不同的改道形式可以 指向 内部一样,线程也可以在单行上使用洗牌:

示例 6:

"Hey! Jesse!" you shout, trying to get her attention. Hearing your voice, she turns, and you hurry to catch up with her as you jog from the building after your class.
{~ <- question_one|<- question_two}
== question_one
<> "How was your class"? you ask.
== question_two
<> "Are you going to the party tonight?" you ask.

示例 6 中,线程被用作洗牌的元素。每次运行故事时,两个线程中的一个将被选择并 编织 到故事中,创造一个新的体验。以这种方式将线程与替代结合使用,在创建作为线程本身访问的故事的 替代 内容时非常有用。

在许多角色扮演视频游戏中常见的一种模式是使用各种玩家统计数据来确定基于测试变量的值,哪些内容是可用的。如果它在某个范围内,内容就可以被 编织 到当前的结构中。这将为读者提供关于行动结果的额外上下文:

示例 7:

VAR strength = 16
VAR intelligence = 16
-> save_or_doom
== save_or_doom
The villain holds the ancient artifact and is moments away from enslaving the world with its limitless power as part of a complex ritual.
* {strength > 15} [Use strength]
    <- use_strength
* {intelligence > 15} [Use intelligence]
    <- use_intelligence
- -> DONE
= use_strength
You throw your hand axe as hard as you can. It strikes the artifact, shattering it into multiple pieces and ending the ritual.
= use_intelligence
You quickly calculate the size of the artifact based on its materials and cast the spell to banish it to another dimension. In a blink of an eye, the ritual ends!

示例 7 中的每个针都包含额外的文本。因为针是 save_or_doom 结的一部分,所以它们可以用作线程的一部分。故事的小节仍然是 部分

示例 7 也使用了条件选项,如第二章中所述的“结、改道和循环模式”,以及第四章中讨论的变量。通过测试 strengthintelligence 变量的值范围,15 个选项都显示出来:

图 5.3 – 从示例 7 中输出的 Inky 的结果

图 5.3 – 从示例 7 中输出的 Inky 的结果

可以使用改道的逆动作创建单个线程。不是移动到某个位置,而是该部分移动到流程的当前时刻。此外,可以将多个线程组合起来,为读者创造一个连续的叙事体验,因为它们被拉在一起。当处理线程时,还有一个更重要的方面:DONE 关键字。在本主题的最后部分,我们将探讨如何关闭线程以及当你使用嵌套线程时这意味着什么。

结束线程

DONEEND关键字在第二章“节点、转向和循环模式”中引入。这两个关键字的使用之间的区别在它们的用法中得到了解释。END关键字停止故事,而DONE关键字停止当前流程。当使用线程时,故事流程也会受到影响。换句话说,DONE关键字关闭了当前流程。在许多情况下,这将是故事本身。当使用线程时,该关键字会关闭线程本身。

在 Inky 中创建节点时,作者通常会收到一个警告,提示在不含该关键字的节点中需要DONE关键字。当处理线程时,这个警告会提示作者注意线程和DONE关键字的重要方面:

示例 8:

<- thread_1
<- thread_2
== thread_1
* This is a choice
-> DONE
== thread_2
* This is another choice
-> DONE

示例 8使用了两个DONE关键字的实例。这可能会显得有些奇怪,但每个关键字的用法都会关闭其自己的线程。在 Inky 中运行时,两个选择,每个选择都在一个单独的线程中,将被合并:

![图 5.4 – 示例 8 的组合线程输出图片

图 5.4 – 示例 8 的组合线程输出

示例 8中,DONE 关键字的单独使用不会相互影响。每个线程都包含在其自身之内。这一点在尝试在DONE关键字之后将第二个线程的包含移动到第一个线程中时变得明显:

示例 9:

<- thread_1
== thread_1
* This is a choice
-> DONE
<- thread_2
== thread_2
* This is another choice
-> DONE

示例 9中,第二个线程出现在DONE关键字的使用之后。与示例 8不同,其中两个选择将合并成一个单一的编织,故事将在第二个线程出现之前结束:

![图 5.5 – 示例 9 中的线程关闭图片

图 5.5 – 示例 9 中的线程关闭

示例 9展示了DONE关键字与线程之间的交互。DONE关键字关闭当前流程。在示例 9中,第一个线程内部的第二个线程永远不会到达,因为它被DONE关键字关闭。

线程和洞穴不是两个独立的概念,而是根据作者的需求以两种不同的方式实现类似结果的方法。在下一个主题中,我们将探讨结合这两个概念的各种方法,以创建更复杂的故事。我们将使用洞穴移动到故事中的某个位置,并检查线程如何重复而不是编写更多代码。

洞穴和线程的结合

洞穴允许故事流程移动到节点或缝合处,然后返回。线程作为其逆过程,将内容从节点或缝合处移动到当前流程位置。共同作用,它们形成了一种强大的方式,可以用来构建由不同部分组成的叙事。在高级项目中,这两个概念通常与编织和聚集点一起使用,以扩展或收缩可能的分支数量。

隧道可以被重复使用,线程可以被重复。在本主题中,我们将探讨如何将线程和隧道结合起来,使用更少的总体代码创建更复杂的故事。

重复使用隧道和重复线程

示例 4使用了多个隧道,而示例 5使用多个线程展示了相同的结果。也可以通过将内容分割成线迹作为每个故事部分的多个结点的一部分来组合多个隧道和线程。例如,许多角色扮演视频游戏开始时先展示角色的对话。然后,他们通过让玩家在做出最终选择之前选择各种选项,来给玩家提供控制的错觉,最终回到相同的选项,直到玩家做出某个选择以继续:

示例 10:

VAR has_rake = false
-> tutorial
== tutorial
= awake
You feel a hand on your shoulder and wake up to a young woman frowning down at you.
Jane: "I see you are finally awake! I wish you would stop sleeping under this tree instead of working."
Jane: "Uncle John is going to catch you one of these days and then you will be in trouble."
Jane: "Do you remember what you need to do today?"
* [What was it again?]
    <- tasks
* [I remember.]
    <- remember
- -> rake ->
-> old_shrine
= tasks
Jane: "In case you forgot, you need to clean up all the leaves around the old shrine."
Jane: "And don't forget to take your rake!"
= remember
Jane: "Good! Get out to that old shrine and finish your cleaning!"
= rake
+ [Pick up rake]
    ~ has_rake = true
+ [Skip the rake]
    ~ has_rake = false
- ->->
== old_shrine
{has_rake == false: You realize you do not have your rake.}
+ {has_rake == false} [Retrieve rake]
    -> tutorial.rake ->
    {has_rake == false: -> old_shrine}
- You begin to rake the leaves around the old shrine.
-> DONE

示例 10重复使用了一个隧道。第一次出现是在读者在old_shrine结点之间选择,并且没有耙子(即,如果has_rake等于false),他们会收到检索耙子选项和隧道的第二个可能实例。

示例 10中,线程被用来分割角色的响应文本。这创建了一个简化的编织模式,文本被分割成其线迹。对于作者来说,这种模式允许他们在不担心编织代码部分的情况下更改或添加响应文本。

最后,收集点(如第一章文本、流程、选择和编织中讨论的)被使用了三次。第一次是折叠第一个编织的可能分支,并创建隧道的第一个实例。第二次是作为rake线迹的一部分发生。这个收集点是两个隧道实例的结束,并折叠了任何选项的结果:has_rake等于true),检索耙子选项不再出现,故事以角色在旧神社耙树叶结束。

“这是一个例子,”丹写道。然而,在示例 8中,角色的名字出现在对话之前,以表明是谁在说话。第十章带有墨水的对话系统将重新审视标签的使用,并探讨一些在视频游戏中编写和标记对话的方法。

带有隧道的线程

当通过代码的内存版本移动时,线程会将故事的一部分移动到当前流程位置。内部来说,这不会改变它们在更大故事代码中的实际位置,但它们与故事运行时当前版本流程的连接会改变。这意味着可以在线程内包含隧道。在这些情况下,流程会“线程”结点或线迹,然后移动到另一个部分,然后再返回:

示例 11:

<- knot_example.stitch_one
<- knot_example.stitch_two
== knot_example
= stitch_one
-> tunnel ->
= stitch_two
-> tunnel ->
== tunnel
This is a tunnel inside a thread!
->->

示例 11 展示了在线程中使用隧道的基模式。在墨迹中这样做是安全的,因为流程线程化通过结或缝合点的方式。更复杂的用法可能是视频游戏对话系统的一部分,其中数据被传递到结以执行不同的微小计算,作为玩家跟随对话某些分支的反应的一部分:

示例 12:

VAR reputation = 10
-> villager_1
== villager_1
Villager: Heroes! You have returned from fighting the monsters in the forest! Did you find any sign of my husband? He has been missing for several days.
+ \(Lie\) We have not found him yet.
    <- adjust_reputation(-10)
+ We found what was left of him. I'm sorry to report he is 
  dead.
    <- adjust_reputation(10)
+ I used his leg to fight off some spiders! Oh. Right... 
  he's, you know, dead.
    <- adjust_reputation(-15)
- -> DONE
== adjust_reputation(amount)
~ reputation = reputation + amount
-> report_reputation ->
== report_reputation
Current reputation: {reputation}

示例 12示例 1 中引入的模式的一个更实际的例子。它使用线程将数据传递到结,就像是一个函数。此外,示例 12adjust_reputation 结内部使用隧道作为连接到 report_reputation 结的连接。对于每个选择,reputation 变量的值将在读者做出选择后更改。新的 reputation 值将作为结果显示。

注意

示例 12 使用反斜杠\,带有开括号(和闭括号),来转义括号的使用,而不是创建一个可选的标签。

摘要

在本章中,我们学习了更多关于转向如何在墨迹中与结和缝合点协同工作的知识。我们探讨了隧道概念如何将墨迹中的两个不同部分连接起来。当故事运行时,流程移动到结或缝合点,然后使用两个转向->->返回。我们还回顾了隧道如何作为更复杂流程模式的一部分被使用,该模式是两个部分之间更长的连接序列。接下来,我们看到了线程,墨迹中的另一个概念,作为转向的逆过程,其中部分被移动到当前流程位置而不是流程移动到其内容。最后,我们检查了一些在线程中使用隧道将数据传递到结并显示变量更改值的模式。

线程和隧道虽然是更高级的概念,但可以使代码更加简洁。线程允许开发者将代码分成不同的部分,然后将它们再次线程化组合在一起。隧道允许开发者以不同的方式实现与线程相同的一般结果。而不是将内容拉在一起,隧道移动到一个结或一个缝合点,然后再返回,隧道化通过故事到达一个位置然后再返回。线程和隧道有其特定的用法,但两者都允许开发者通过更有效地使用它们的各个部分来创建更复杂的项目。

在下一章中,我们将继续使用 ink-Unity Integration 插件。虽然 Inky 已经被用来显示墨迹代码输出,但 ink-Unity Integration 插件将允许我们更全面地控制墨迹故事如何运行。在接下来的章节中,我们还将学习如何使用 C# 和 ink API 来进行选择、更改变量的值,甚至访问墨迹代码中的函数。

问题

  1. 要从结或缝合点中的隧道返回,必须使用哪个墨迹概念两次?

  2. 墨迹中的隧道是如何工作的?

  3. 线程与转向和隧道有何不同?

  4. 是否可以在同一行上使用多个线程?

第二部分:ink Unity API

在你完成本节内容后,你将能够使用 Unity 中的 ink-Unity 集成插件在墨迹故事中做出选择和访问内部值。本节包含以下章节:

  • 第六章, 添加和使用 ink-Unity 集成插件

  • 第七章, Unity API – 做出选择和故事进展

  • 第八章, 故事 API – 访问墨迹变量和函数

  • 第九章, 故事 API – 观察和响应故事事件

第六章:第六章:添加和使用 ink-Unity Integration 插件

本章首先讨论如何在 Unity 项目及其项目窗口中添加 .ink 文件及其编译形式 .json。然后,我们将回顾如何将 Inky 与 ink 源文件关联,并使用它直接从 Unity 编辑文件。最后,我们将通过检查如何调整项目的插件设置来结束本章。

在本章中,我们将涵盖以下主要主题:

  • 添加 ink-Unity Integration 插件

  • 处理 ink 文件

  • 调整插件设置

在本章中,我们将查找、导入和使用 ink-Unity Integration 插件。这将使我们能够处理 ink 文件并调整插件设置。没有插件,我们无法处理 ink 文件,本章中概述的步骤将帮助开发者为后续章节设置包,这些章节将专注于处理 ink 文件和安装插件后可用的 Story API。

关于 Unity 版本的说明

本章已在 Unity 2020.3 (LTS)Unity 2021.1 (当前版本) 上进行测试。本章还涵盖了 ink-Unity Integration 插件的 1.0.2 版本。Inkle 报告称,ink-Unity Integration 插件的 1.0.2 版本与 Unity 的 2018.4 及更高版本兼容,但推荐使用的是 2020.3 (LTS)2021.1 (当前版本)

技术要求

本章中使用的示例,在 *.ink 文件中,可以在 GitHub 上找到:github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language/tree/main/Chapter6.

添加 ink-Unity Integration 插件

没有特殊的包,即 ink-Unity Integration 插件,我们无法在 Unity 中处理 Ink 文件。像其他 Unity 包一样,它只能添加到现有项目中,并且必须重新导入以供任何想要使用其代码和可用 API 的新项目使用。在本主题中,我们将介绍查找、导入和验证插件是否已准备好在项目中使用的步骤。本主题中的每个部分都应与第一个部分,即 查找和导入插件,使用相同的项目,从基于 2D 模板创建新 Unity 项目的说明开始。

注意

该包的官方名称是 ink-Unity Integration。然而,ink 的创造者 Inkle 在其自己的文档中称这个包为 插件。本书遵循相同的命名约定以避免混淆。

查找和导入插件

查找和导入 ink-Unity Integration 插件的过程需要以下步骤:

  1. 使用内置的 2D 模板创建一个新的 Unity 项目。

    警告

    Inkle 不建议使用 Unity Asset Store 版本的 ink-Unity Integration 插件,因为更新之间存在延迟。本书将使用插件开发者推荐的安装方法来获取最新版本。

  2. 导航到 ink-Unity 集成插件的OpenUPM页面:openupm.com/packages/com.inklestudios.ink-unity-integration/

  3. 点击右侧的获取安装器.unitypackage链接:![图 6.1 – ink-Unity 集成插件的 OpenUPM 页面 图片

    图 6.1 – ink-Unity 集成插件的 OpenUPM 页面

  4. 点击获取安装器.unitypackage链接将提示文件下载。下载完成后,找到本地文件并运行安装程序。

  5. 在 Unity 打开时打开下载的安装文件。这将打开 Unity 中的导入 Unity 包窗口并加载安装文件的 内容。![图 6.2 – 显示 ink-Unity 集成的导入 Unity 包窗口 图片

    图 6.2 – 显示 ink-Unity 集成的导入 Unity 包窗口

  6. 导入 Unity 包窗口中,点击全部然后导入。这将确保所有文件都被选中并导入。一旦导入完成,Unity 将显示一条消息,说明已向项目中添加了一个新的作用域注册表。图 6.3 – Unity 中导入作用域注册表的窗口

    图 6.3 – Unity 中导入作用域注册表的窗口

  7. 点击关闭按钮关闭导入作用域注册表窗口。Unity 还打开了项目设置窗口。这也可以关闭。

作为最后一步,我们将测试包是否已安装并准备好使用。在下一节中,我们将与包管理器窗口一起工作。

验证包是否已安装

根据 Unity 的版本、使用的其他包或开发者设置,有时可能不清楚 ink-Unity Integration 包是否已安装并准备好使用。

要验证插件已启用并准备好使用,请按照以下步骤操作:

  1. 通过点击文件菜单中的窗口然后点击包管理器来打开包管理器窗口:![图 6.4 – Unity 中已选中包管理器的窗口菜单 图片

    图 6.4 – Unity 中已选中包管理器的窗口菜单

  2. 包管理器窗口中,点击下拉菜单,并确保在项目中被选中:![图 6.5 – 包管理器,已选中“在项目中”选项 图片

    图 6.5 – 包管理器,已选中“在项目中”选项

  3. 如果 ink-Unity 集成插件包含在当前项目中正在使用的包列表中,并且其名称旁边有一个绿色的勾选标记表示已安装到当前项目中,则该插件已准备好使用。img/Figure_6.6_B17597.jpg

    img/Figure_6.6_B17597.jpg

    图 6.6 – 包管理器显示已安装的 ink Unity 集成包

  4. 在验证 ink Unity 集成包已安装后,可以关闭包管理器窗口。

在下一节中,我们将继续介绍如何处理 ink 文件。安装 ink-Unity 集成插件后,我们将学习如何创建 ink 文件、编辑它们以及使用插件内置的自动编译过程。

处理 ink 文件

Unity 只了解它所监视的文件夹内的文件。要处理其他文件,必须将它们作为新资产添加到现有项目中。安装 ink-Unity 集成插件后,Unity 将监视所有新的.ink文件类型文件,并将自动根据其项目设置编译它们以供 Unity 项目使用。然而,第一步是使用插件将这些 Ink 源文件添加到现有 Unity 项目中。

添加 ink 源文件

使用 Inky 创建的文件以.ink文件类型保存。这些是项目的文件。它们是 Ink 故事的代码形式。要在 Unity 中使用 ink,第一步是创建一个新的.ink文件。

在 Unity 中创建新的 ink 源文件,请打开一个现有项目并确保项目窗口已打开。(如果未打开,可以通过点击窗口菜单中的通用然后项目来重新打开。)

在 Unity 中做同样的事情通常有多种方式。这同样适用于在项目窗口中创建新文件。创建新 ink 文件的一种方法是使用项目窗口的工具栏和创建菜单,然后选择Ink选项。也可以通过在项目窗口中右键单击并进入创建菜单然后选择Ink来创建:

img/Figure_6.7_B17597.jpg

图 6.7 – 项目窗口中的创建菜单

创建的 Ink 源文件可以重命名,或者可以通过单击其文件名区域外接受默认名称“新”Ink。在创建后不久,ink-Unity 集成插件将生成一个与创建的文件同名的新文件。

img/Figure_6.8_B17597.jpg

img/Figure_6.8_B17597.jpg

图 6.8 – ink-Unity 集成插件在 Unity 项目视图生成的文件

自动编译设置

如果 Unity 没有根据墨迹源文件自动生成.json文件,则可能是自动编译设置被关闭了。请参考本章后面关于“调整插件设置”主题中的“更新自动重新编译”部分,了解如何更改此设置。

点击生成的文件将显示它是一个 .json 文件。当 ink 运行故事时,它会将其称为 .ink 文件的内容编译成 .json 文件。

安装 ink-Unity 集成插件后,将为每个现有的 .ink 文件自动创建一个新的 .json 文件。插件还将跟踪更改,并在检测到新更改时重新编译项目。

使用 Inky 编辑源文件

ink 源文件最好使用 Inky 编辑。然而,新的 ink 源文件通常通过 Unity 项目的 .ink 文件添加,这些文件可以在 Unity 内打开时与 Inky 关联以进行编辑。

根据操作系统,说明不同。接下来的两个部分包含 Windows (10 及以后)macOS (11.1 及以后) 的步骤。

Windows:将 Inky 与 ink 源文件关联

要在 Windows 10 及以后开始将 Inky 与所有 .ink 文件关联,请按照以下步骤操作:

  1. 在 Unity 的 项目 窗口中点击创建的文件。这将将其在 检查器 视图中打开:图 6.9 – ink 源文件的检查器视图

    图 6.9 – ink 源文件的检查器视图

  2. 点击 .ink 文件。图 6.10 – Windows 10 中的文件关联提示

    图 6.10 – Windows 10 中的文件关联提示

  3. 点击 更多应用 并滚动到列表底部:![图 6.11 – Windows 10 中的程序列表 图 6.11 – Windows 10 中的程序列表

    图 6.11 – Windows 10 中的程序列表

  4. 点击找到的 Inky.exe 文件并选择它:图 6.12 – Windows 10 中的应用程序选择器

    图 6.12 – Windows 10 中的应用程序选择器

  5. 点击 .ink 文件。几秒钟后,Windows 将在 Inky 中打开 Unity 的 项目 窗口中找到的 .ink 文件。

今后,假设 Inky.exe 文件未被删除,Unity 将将所有 .ink 文件的打开操作重定向到 Inky。

macOS:将 Inky 与 ink 源文件关联

要在 macOS (11.1 及以后) 中开始将 Inky 与所有 .ink 文件关联,请按照以下步骤操作:

  1. 在 Unity 的 项目 窗口中右键单击创建的文件。点击 在 Finder 中显示图 6.13 – macOS 中 Unity 的文件上下文菜单

    图 6.13 – macOS 中 Unity 的文件上下文菜单

  2. Finder 打开后,右键单击文件并导航到 打开方式图 6.14 – macOS 中的打开方式文件上下文菜单

    图 6.14 – macOS 中的打开方式文件上下文菜单

    图 6.14 – macOS 中的打开方式文件上下文菜单

  3. 如果 Inky.app 未出现,点击 其他…图 6.15 – macOS 中的应用程序选择器

    图 6.15 – Windows 10 中的程序选择器

    图 6.15 – macOS 中的应用程序选择器

  4. 在列表中搜索 InkyInky.app 中。点击 打开

今后,假设 Inky.app 文件未被删除,Unity 将将所有 .ink 文件的打开操作重定向到 Inky。

更新 ink 源文件

一旦 Inky 与墨迹源文件关联,编辑文件就会变得容易得多。双击.json文件中的文件,这意味着源文件和编译文件将始终是最新的。

  1. 要查看此过程实际操作,请双击“添加墨迹源文件”部分中创建的墨迹文件,以在 Inky 中打开它。

  2. 将其内容更改为以下示例 1

    Hello! This is an Ink source file!
    
  3. 通过点击.json文件保存文件在 Inky 中。控制台窗口也会显示此过程何时开始和完成。

![图 6.16 – 显示墨迹编译信息的控制台窗口图片

图 6.16 – 显示墨迹编译信息的控制台窗口

在 Unity 项目中安装了 ink-Unity 集成插件并与.ink 文件关联的 Inky,可以将新的墨迹源文件添加到 Unity 项目中,然后使用 Inky 进行编辑。每次保存时,ink-Unity 集成插件将根据其项目设置重新编译它们。更新墨迹源文件变得像将它们添加到 Unity 项目并在 Inky 中编辑它们一样简单。

在 ink-Unity 集成插件准备就绪后,我们将在下一节中检查其设置以及如何更新自动编译功能。

调整插件设置

ink-Unity 集成插件包含多个可以根据 Unity 项目的需求进行更改的设置。本主题将回顾如何找到项目设置窗口并更新一个常见选项 – 自动重新编译。

查找 ink-Unity 集成设置

Ink-Unity 集成插件包含默认设置。这些可以通过作为项目设置的一部分进行编辑来更改:

  1. 点击“编辑”然后项目设置图 6.17 – 已选择项目设置的编辑菜单

    图 6.17 – 已选择项目设置的编辑菜单

  2. 从侧边栏选项中点击墨迹以查看项目的相关设置。

![图 6.18 – Unity 中的墨迹项目设置图片

图 6.18 – Unity 中的墨迹项目设置

在下一节中,我们将使用项目设置窗口来更新一个常见设置,即墨迹源文件的自动重新编译。

更新自动重新编译

如果项目有一个大的墨迹源文件或者许多不同的较小文件,每个文件都使用 ink 中的INCLUDE关键字,那么每次文件更改时编译过程可能需要超过几秒钟。在这些情况下,关闭墨迹源文件的重新编译可能防止 ink-Unity 集成插件浪费时间重新编译墨迹源文件。

  1. 项目设置窗口中,点击墨迹:![图 6.19 – Unity 中的墨迹项目设置 图片

    图 6.19 – Unity 中的墨迹项目设置

  2. 点击自动编译所有墨迹旁边的复选框以禁用自动编译过程。(稍后可以通过再次点击复选框来重新启用。)

在这个主题中,我们检查了 Ink 项目设置窗口并更新了自动编译选项。根据 ink 源文件的大小和其他因素,编译过程有时在更改之间可能需要太长时间。更新 ink 源文件的自动编译通常是一个非常有用的设置,需要了解并更新,具体取决于项目。

摘要

在本章中,我们作为第一个主题的一部分学习了如何在网络上找到 ink-Unity 集成插件。我们回顾了如何导入包以及验证它已被安装。这对于所有使用该插件的项目来说是一个重要步骤,因为对于任何新项目都必须重新导入。

在第二个主题“与 ink 文件一起工作”中,我们探讨了如何在 Unity 中创建新的 ink 文件。我们检查了如何在 Windows 10 和 macOS 中将 Inky 与 ink 源文件关联起来。然后我们学习了如何编辑 ink 文件以及 ink-Unity 集成插件将如何检测任何更改,如果启用了项目设置中的选项,它将重新创建编译后的 JSON 文件。

最后,在调整插件设置主题中,我们检查了使用 ink-Unity 集成插件时的设置。我们首先回顾了如何通过从可用选项中选择Ink来找到插件的项目设置。接下来,我们检查了如何调整 ink 文件的自动重新编译。

在下一章中,我们将继续使用 Ink API 来处理运行中的故事。ink-Unity 集成插件帮助根据 ink 源文件生成 JSON 文件。我们将在下一章中使用这些 JSON 文件,并学习如何将故事的部分作为更大 Unity 项目的一部分来加载。

问题

  1. Inkle 是否推荐使用 Unity Asset Store?

  2. 使用 ink-Unity 集成插件在 Unity 中创建 ink 文件至少有一种方法吗?

  3. 哪个程序是编辑 ink 文件的不错选择?

  4. ink-Unity 集成插件的自动编译过程可以更改吗?

第七章:第七章:Unity API – 做出选择和故事推进

本章首先回顾了如何在 Unity 中将script组件添加到游戏对象中。通过创建与 C#文件关联的script组件,可以编写代码从 ink 源文件加载 ink-Unity 集成插件创建的编译后的 JSON 文件,作为 Unity 场景的一部分。接下来,我们将探讨如何加载 ink 故事并开始推进它。我们将看到如何通过编程选择 ink 提供的选项,然后如何作为结果继续故事推进。我们将以在 Unity 中向玩家展示多个用户界面元素的常见方法为例。用户将能够在 Unity 中点击按钮并引导 ink 故事的推进。

在本章中,我们将涵盖以下主要主题:

  • 加载编译后的 ink 故事

  • 程序化选择选项

  • 创建动态用户界面

技术要求

本章中使用的示例,在*.ink文件中,可以在 GitHub 上找到:github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language/tree/main/Chapter7

加载编译后的 ink 故事

第六章中,添加和使用 ink-Unity 集成插件,我们学习了如何将新的 ink 文件添加到 Unity 项目中。在导入插件后,可以使用项目窗口中的创建菜单创建新文件。当添加 ink 源时,插件会自动创建一个编译后的 JSON 文件。随着我们现在开始使用插件提供的 ink API 进行工作,我们将使用创建的 JSON 文件来处理故事。

在 Unity 中使用代码的第一步是创建一个GameObject。这是 Unity 中的一个基本容器。每个GameObject至少包含一个组件。Unity 中的不同系统,如渲染系统(用于在屏幕上绘制事物)、物理系统(用于检测屏幕上两个事物是否重叠)和输入系统(用于检测用户是否按下了按钮)都与这些组件进行通信。当 Unity 运行项目时,它会将数据发送到与它关联的系统所匹配的组件。例如,要处理来自输入系统的数据,需要一个输入组件。

要在 Unity 中使用代码,需要一个script组件。添加到 Unity 项目中的所有代码都是通过成为不同系统的一部分来工作的。script组件允许开发者编写用于处理游戏对象及其包含的不同组件的代码。与其他主要从不同系统接收数据的组件不同,script组件可以脚本化其他对象和值。通过代码,它可以指示其他组件在发生不同事件(如用户点击按钮)时更改其值。

创建脚本组件

任何游戏对象都可以有一个 script 组件。然而,为了更好的组织,通常为每个类型的数据、行为或与项目相关的任务创建一个新的 GameObject 是很有用的。这通过 GameObject 将每个新的动作或可能的事件分开,使得在大项目中的不同部分工作变得更加容易:

  1. 打开一个新的或现有的 Unity 项目。

  2. 如果尚未添加,请确保安装 ink-Unity Integration 插件。

    在 Unity 中做事情总是有多种方式,创建新的游戏对象也是如此。创建新游戏对象最简单的方法之一是使用 GameObject 菜单。

  3. 点击 GameObject,然后点击 Create Empty。![图 7.1 – GameObject 菜单 图 7.1 – B17597.jpg

    图 7.1 – GameObject 菜单

  4. 将创建一个新的 GameObject 并添加到 GameObject 中,将显示其当前组件。GameObject 仅仅是一个容器。其组件执行与运行项目相关的所有工作。甚至 GameObject 的名称也是其组件包含的一个值。

  5. 要更改创建的 GameObject 的名称,请点击它,在 GameObject(默认值)更改为 Ink Story。![图 7.3 – 在 Unity 中更改 Ink Story 名称 图 7.3 – B17597.jpg

    图 7.3 – 在 Unity 中更改 Ink Story 名称

    新命名的 Ink Story 将成为运行墨迹故事相关组件的容器。将游戏对象名称更改为 Ink Story 使得在项目中众多其他对象中更容易找到它,同时也解释了它在项目中的作用。

  6. Inspector 视图中显示 Ink Story 的组件,点击 Add Component。![图 7.4 – 添加组件菜单中的组件列表 图 7.4 – B17597.jpg

    图 7.4 – 添加组件菜单中的组件列表

  7. 在列表中点击 New script图 7.5 – 新脚本组件创建

    图 7.5 – 新脚本组件创建

  8. 将此新的脚本文件命名为 inkLoader.cs

    注意

    点击 script 组件名称并不总是允许在 Unity 中重命名文件。按键盘上的向下箭头两次将选择从搜索移动到文件标题。

    图 7.6 – 脚本重命名为 InkLoader

    图 7.6 – 脚本重命名为 InkLoader

  9. 重命名文件后,点击 Create and Add 按钮。一个新的 C# 文件将被添加到 Project 窗口中。![图 7.7 – Unity 中的新 InkLoader.cs 文件 图 7.7 – B17597.jpg

    图 7.7 – Unity 中的新 InkLoader.cs 文件

  10. 双击此文件以在 Visual Studio 中打开它进行编辑。

本节已包含了一个逐步过程,用于准备 Unity 项目以使用 ink Story API。我们看到了如何创建GameObject并添加script组件。在下一节中,我们将在此基础上构建项目,开始使用作为 ink-Unity Integration 插件部分添加到 Unity 中的 Story API。

添加 ink Story API

安装 ink-Unity Integration 插件会添加一个额外的Ink。它包含三个其他命名空间,分别命名为ParsedRuntimeUnityIntegration,每个命名空间都包含与其名称相关的类。要处理编译后的 ink JSON 文件,需要Ink.Runtime命名空间。这告诉 Unity 应该从ink命名空间开始,然后找到其中名为Runtime的命名空间:

  1. 创建脚本组件部分打开的文件中,在已创建文件中已有的那些之后添加一个新的using行:

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using keyword tells Unity to include the Ink.Runtime namespace and allow its classes to be used as part of this file.
    
  2. 接下来,创建一个名为inkJSONAsset的公共字段,并将Start()方法更改为以下内容:

    public class InkLoader: MonoBehaviour
    {
        InkJSONAsset field with the public keyword will allow this value to be changed inside the Unity editor. The addition of the Story class creates a new ink story as part of the Runtime namespace. This is known as the *Story API* because multiple methods will be used as part of the Story class.
    
  3. 在 Visual Studio 中保存InkLoader.cs文件,并返回 Unity。

  4. 一段时间后,Unity 将刷新并重新加载更改后的 C#文件。

  5. 最后一步是将一个 ink JSON 文件与创建的 C#文件关联。在InkStory游戏对象中,在script组件中,如下截图所示:![Figure 7.8 – 新的 Ink JSON 资产属性在检查器视图中 图片

    Figure 7.8 – 新的 Ink JSON Asset 属性在检查器视图中

    属性显示值为None (Text Asset)。这意味着没有文件与此属性关联。要更改此,需要添加一个编译后的 JSON 文件。

    注意

    下一步需要 ink JSON 文件。如果没有创建,可以通过创建 ink 文件并让自动编译选项创建一个,或者点击现有的 ink 源文件,然后在检查器视图中点击编译来创建一个新的 JSON 文件。

  6. 点击值旁边的TextAsset选择圆圈以打开选择 TextAsset窗口。![Figure 7.9 – 选择 TextAsset 窗口 图片

    Figure 7.9 – 选择 TextAsset 窗口

  7. 选择一个 ink 编译的 JSON 文件。![Figure 7.10 – 更新的 Ink JSON Asset 属性 图片

    Figure 7.10 – 更新的 Ink JSON Asset 属性

    Ink JSON Asset属性值更新后,关闭选择 TextAsset窗口。

  8. 在 Unity 编辑器的中间点击Play按钮。

![Figure 7.11 – Unity 中的播放按钮图片

Figure 7.11 – Unity 中的播放按钮

Unity 将运行当前场景,看起来似乎没有发生任何事情。如果没有错误出现在控制台窗口中,则一切运行正确。内部,Unity 已加载编译后的 ink JSON 文件,并准备好运行 ink 故事。

通过第二次点击Play按钮停止运行场景。

运行 ink JSON 文件

ink 故事使用 Story 类和方法运行。加载 ink JSON 文件只是第一步。必须告诉 Story 类一次加载故事的一个或多个

当之前使用 Inky 运行 ink 源文件时,它一次显示一行,并在它们之间有一个空行:

示例 1:

This is the start.
And then this happens.

当在 Inky 中运行时,示例 1 生成以下输出:

图 7.12 – 示例 1 输出

图 7.12 – 示例 1 输出

在 Inky 中,它创建的额外行是其自身使用 Story API 的结果。为了复制此输出,我们需要添加一个新方法:Continue()

  1. 在作为 添加 ink Story API 部分的一部分使用的相同文件中,在 Inky 中打开文件进行编辑。

  2. 将新 ink 源文件的内容更改为 示例 1 并然后在 Inky 中保存该文件。保存文件后不要关闭 Inky。现在返回 Unity。

  3. 在检测到 ink 源文件的更改后,ink-Unity Integration 插件将自动重新编译 ink JSON 文件。因为它作为 添加 ink Story API 部分的一部分与 Ink JSON Asset 属性相关联,ink JSON 文件也将始终正确加载。

  4. 如果 InkLoader.cs 文件尚未在 Visual Studio 中打开,请双击 项目 窗口中的它。

  5. 将以下行添加到 Start() 方法中:

    void Start()
    {
    Story exampleStory = new Story(InkJSONAsset.text);
      Debug.Log(exampleStory.Continue());
    }
    
  6. 保存更改后的 inkLoader.cs 文件并返回 Unity。

  7. 点击 播放 按钮来运行当前场景。

    这次,控制台 窗口将显示一条消息。

图 7.13 – Unity 中的控制台窗口

图 7.13 – Unity 中的控制台窗口

Debug.Log() 方法使用 Continue() 方法返回的内容作为 Story 类的一部分,在 控制台 窗口中显示一条消息。

每次调用 Continue() 方法时,它都会加载 ink 故事的下一行,并返回一个表示它的字符串。然而,该方法有一个问题:它无法检测故事的结尾。为此,需要一个不同的属性。

再次点击 播放 按钮来停止正在运行的场景。

检查故事是否可以继续

Continue() 方法会在有可用的情况下加载故事的下一行。在 示例 1 的代码中,有两行。

  1. 返回 Visual Studio 并编辑 Ink Loader.cs 文件。将 Story() 方法更改为以下内容:

    void Start()
    {
    Story exampleStory = new Story(InkJSONAsset.text);
    Debug.Log(exampleStory.Continue());
    Debug.Log(exampleStory.Continue());
    }
    
  2. 在添加新行代码后保存 InkLoader.cs 文件。返回 Unity 并点击 播放 按钮来播放当前场景和更新后的文件。

  3. Continue() 方法传递给 Debug.Log() 方法。

  4. 再次在 Unity 中点击 播放 按钮来停止当前场景。

  5. 返回 Visual Studio 并编辑 InkLoader.cs 文件。将以下代码添加到 Start() 方法中:

    void Start()
    {
    Story exampleStory = new Story(InkJSONAsset.text);
    Debug.Log(exampleStory.Continue());
    Debug.Log(exampleStory.Continue());
    Debug.Log(exampleStory.Continue());
    }
    
  6. 保存更新的 InkLoader.cs 文件。

  7. 返回 Unity 并播放场景。

    使用 Continue() 方法的第三次调用时,将发生错误并在 控制台 窗口中显示。

    图 7.15 – Unity 控制台中 Continue() 错误

    图 7.15 – Unity 控制台中的 Continue() 错误

  8. 点击 Continue() 方法不会检查是否有其他行要加载。当没有更多内容时,它会抛出一个错误。

    为了解决这个问题,需要一个在错误中提到的属性。Story 类提供了 canContinue 属性来检查是否有更多故事内容要加载。它包含一个布尔值。如果有更多内容,canContinue 将为 true。否则,它将为 false

  9. 返回 Visual Studio 并编辑 InkLoader.cs 文件。将 InkLoader.cs 文件中的 Start() 方法更新如下:

    void Start()
    {
    Story exampleStory = new Story(InkJSONAsset.text);
    while(exampleStory.canContinue)
          {
              Debug.Log(exampleStory.Continue());
          }
    }
    
  10. 在 Visual Studio 中保存编辑后的 InkLoader.cs 文件。

  11. 返回 Unity 并再次播放场景。

使用 while 循环,故事将逐行加载,直到没有内容为止。一旦发生这种情况,canContinue 属性将更改为 false,循环结束。

图 7.16 – 使用更新后的 while 循环的 Console 窗口

图 7.16 – 使用更新后的 while 循环的 Console 窗口

canContinue 属性与 Continue() 方法结合使用是使用 Story API 时的常见模式。更高级的使用模式可能不会使用 while 循环,但属性和方法通常会一起出现。

以编程方式选择选项

仅显示 ink 故事的文本具有有限的有用性。大多数高级 ink 故事使用 weave 来展示不同的选项。除了 Continue() 方法和 canContinue 属性外,Story API 还有一个名为 currentChoices 的属性,它包含由最近的 weave 生成的选项列表。

如在 检查故事是否可以继续 部分所示,canContinue 属性受 Continue() 方法的影响。在每行加载并返回为字符串后,如果还有更多故事要加载,Story 类将更新 canContinue 属性。这也适用于 currentChoice 属性。当使用 Continue() 方法时,它将加载下一行 以及 任何 weave。

注意

任何之前在本章中使用的游戏对象或 C# 文件都可以安全删除。本节将创建一个新的游戏对象和脚本组件,并使用不同的代码来处理 weave 和选项。

检测 ink 选项

对 weave 进行操作的第一步是检测其选项是否已通过 currentChoices 属性加载。这意味着需要 canContinue 属性和 Continue() 方法。第一个属性防止尝试加载可能不存在的内容,第二个属性则加载当前行和任何沿途的 weave:

  1. 在一个没有使用 Story API 的新或现有 Unity 项目中,创建一个新的空 GameObject。将其命名为 Ink Choices图 7.17 – ink Choices GameObject

    图 7.17 – ink Choices GameObject

  2. 如在 创建脚本组件 部分所示,在 Ink Choices 游戏对象上创建一个新的 script 组件。将此新文件命名为 LoadingChoices.cs图 7.18 – Assets 窗口中的 LoadingChoices.cs 文件

    图 7.18 – Assets 窗口中的 LoadingChoices.cs 文件

  3. Assets 窗口中双击 LoadingChoices.cs 文件,以在 Visual Studio 中打开它进行编辑:

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using Ink.Runtime;
    public class LoadingChoices : MonoBehaviour
    {
        public TextAsset InkJSONAsset;
        void Start()
        {
            Story InkStory = new Story(InkJSONAsset.text);
            InkStory.Continue();
            foreach (Choice c in InkStory.currentChoices)
            {
                Debug.Log(c.text);
            }
        }
    }
    
  4. 在 Visual Studio 中保存文件并返回 Unity。

  5. 创建一个新的 ink 文件,并将其命名为(或重命名为)Example3.ink图 7.19 – Assets 窗口中的 Example3.ink 文件

    图 7.19 – Assets 窗口中的 Example3.ink 文件

  6. 按照在 运行 ink JSON 文件 部分中的说明,将自动生成的 ink JSON 文件与 Ink Choices 游戏对象属性关联。图 7.20 – 与 ink JSON 资产属性关联的 Example3.json 文件

    图 7.20 – 与 ink JSON 资产属性关联的 Example3.json 文件

  7. 在 Inky 中打开 Example3.ink 文件进行编辑。将其更改为以下内容:

    Sam reached out, not quite touching Juan.
    * "Are you just going to leave me?"
    * "He didn't mean anything to me!"
    * "Can't we just start again?"
    
  8. 保存更改后的 Example3.ink 文件。返回 Unity 并运行场景。现在 Unity 的 控制台 窗口将显示每个选项的文本内容。

图 7.21 – 控制窗口中的 Example 3 选项

图 7.21 – 控制窗口中的 Example 3 选项

使用 Continue() 方法不仅加载了 Example 3 的第一行,还加载了代码中出现的第一个编织。currentChoices 属性包含每个编织中存在的选项的对象的 List<Choice>List<Choice> 的每个元素都是一个 Choice 对象,这是一个包含两个重要属性的特定期类:indextext

foreach 循环中,检索每个 Choice 对象的 text 属性。然后将其传递给 Debug.Log() 方法。当运行时,ink 故事被加载。接下来,加载第一行和编织。在循环内部,使用 currentChoices 属性检索每个 text 属性的值。每个值随后在 Debug.Log() 方法中显示。

使用 Unity API 进行选择

玩家通过选择选项来继续故事。在 ink 源代码中,使用星号(*)或加号符号(+)创建一个选择。当运行时,ink 运行时代码作为 Story 类的一部分从这些源代码选择中创建 选项。然而,要在一个 ink 故事中前进,必须 做出 选择。它必须存在于代码中,然后作为选项呈现。

Story类提供了一个名为ChooseChoiceIndex()的方法。该方法接受currentChoices属性中当前元素总数范围内的一个索引int)。currentChoices列表中的每个Choice对象都有indextext属性。在检测墨水选择部分,使用了text属性来显示从墨水源文件生成的选项。要做出选择,则使用其index属性:

  1. 双击检测墨水选择部分中的LoadingChoices.cs文件,如果它尚未在 Visual Studio 中打开,则打开它进行编辑。

  2. 将文件更新如下:

    void Start()
    {
    Story InkStory = new Story(InkJSONAsset.text);
    InkStory.Continue();
    Choice exampleChoice = InkStory.currentChoices[0];
    InkStory.ChooseChoiceIndex(exampleChoice.index);
    Debug.Log(InkStory.Continue());
    }
    
  3. 在 Visual Studio 中保存文件,返回 Unity,并运行场景。

currentChoices属性中的0)位置元素。

图 7.22 – 控制窗口中从 Example 3 选择的选项

图 7.22 – 控制窗口中从 Example 3 选择的选项

ChooseChoiceIndex()方法根据exampleChoiceindex属性在编织中选择第一个选项。然后,在 Unity 的Continue()方法中显示出来。

当使用 ink 和 Unity 进行选择时,需要按顺序发生一系列事情。首先,必须加载一个故事。其次,至少需要加载一行,该行还包含一个编织点。接下来,必须使用Story类的currentChoices属性来检索为玩家创建的选项。然后,需要使用从currentChoices属性检索到的Choice对象的index属性来使用ChooseChoiceIndex()方法。最后,需要加载故事的下一部分。这次额外的加载将包括使用ChooseChoiceIndex()方法选择的选项的文本(如果未使用选择性输出)。然后,故事的其余部分可以继续。

加载所有文本直到下一个编织点

虽然对于加载故事内容很有用,但必须多次使用Continue()方法逐行加载。与使用 Unity API 进行选择部分中的代码一样,这意味着它需要出现在多行代码中。为了预见这个问题,Story API 还包括一个名为ContinueMaximally()的方法。

与逐行加载不同,ContinueMaximally()方法会加载所有内容,直到遇到一个编织点。对于许多项目来说,当编织点之间或由墨水内部作为编织本身的一部分生成多行文本时,这是一种首选的方法:

  1. 在 Unity 中创建一个新的墨水源文件。命名(或重命名)文件为Example4.ink

  2. 在 Inky 中打开Example4.ink进行编辑,并将其更新如下:

    You read all the books and convinced your parents into going to the zoo. You just had to know. 
    You enter the area containing the snakes and walk up to the glass.
    -> snake_house
    == snake_house
    + (tap){tap < 2}[Tap the glass and say something {tap > 0: again}]
        {tap <= 1: You tap on the glass in front of you.
          The snake turns slightly toward the noise and
          sticks out its tongue.}
        {tap > 1: No, you finally decide. You cannot talk
          to snakes.}
        -> snake_house
    + [Ignore the snake]
        You regard the coiled snake and then walk out.
        {tap > 1: What were you thinking? Talking to
          snakes is fictional.}
        -> DONE
    
  3. 更新Example4.ink文件,将其内容从Example 4中提取。

  4. 点击Example3.jsonExample4.json图 7.23 – 在检查器视图中更新的 Example4.json 值

    图 7.23 – 在检查器视图中更新的 Example4.json 值

  5. 双击 LoadingChoices.cs 以在 Visual Studio 中打开它进行编辑。

  6. 将文件更新如下:

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using Ink.Runtime;
    public class LoadingChoices : MonoBehaviour
    {
        public TextAsset InkJSONAsset;
        void Start()
        {
            Story InkStory = new Story(InkJSONAsset.text);
            Debug.Log(InkStory.ContinueMaximally());
            Choice exampleChoice =
            InkStory.currentChoices[0];
            Debug.Log(exampleChoice.text);
            InkStory.ChooseChoiceIndex
              (exampleChoice.index);
            Debug.Log(InkStory.ContinueMaximally());
        }
    }
    
  7. 在 Visual Studio 中保存更改,返回 Unity,并运行场景。Figure 7.24 – Loaded lines and choice text from Example 4

    Figure 7.24 – Loaded lines and choice text from Example 4

    图 7.24 – 从示例 4 加载的行和选择文本

  8. 停止正在运行的场景。

ContinueMaximally() 方法的第一次使用加载了前两行和编织点。接下来,ChooseChoiceIndex() 方法选择了第一个选项。第二次 ContinueMaximally() 方法的使用,与内部转向相结合,再次加载了下一行和编织点。

当与循环结构一起工作时,ContinueMaximally() 方法通常比使用 Continue() 方法更好。使用 ContinueMaximally() 方法将始终加载所有新的文本,直到遇到下一个编织点。对于文本可能在编织点之间出现的情况,单次使用 ContinueMaximally() 方法可以达到与多次调用 Continue() 方法加载相同内容相同的效果。

本主题从使用 currentChoices 属性检测运行中的墨迹故事中的选择开始。然后,我们转向制作选择,包括在墨迹代码中创建它们,然后使用 ChooseChoiceIndex() 方法来选择它们。最后,我们看到了如何将 ContinueMaximally() 方法与 currentChoices 属性和 ChooseChoiceIndex() 方法结合使用。在下一个主题中,我们将扩展这些概念。为了创建一个动态界面,我们可以使用我们对 Story API 的知识来关联 GameObjects 用户界面,并在屏幕上点击按钮与推进墨迹故事之间建立连接。

创建动态用户界面

Story 类提供了多种加载和推进故事的方法。然而,如果没有用户界面,玩家将无法在选项之间进行选择并看到结果。为了解决这个问题,需要额外的游戏对象来显示文本并为用户提供一个点击不同内容的界面。

首先,需要一个新项目。而不是示例代码,这将使用不同的用户界面对象来与用户一起工作。项目还需要创建一个 GameObject,在运行时可以从 GameObject 中移动以成为 预制件

ContinueMaximally() 方法返回的当前行和在 currentChoices 属性中的选择在墨迹故事运行时可能具有动态性。结合预制件,C# 代码可以通过玩家点击按钮在故事中进行选择来动态地重新创建界面。

在这个主题中,我们将通过从创建一个新的 Unity 项目开始,并创建必要的游戏对象,来逐步学习如何创建一个动态界面。接下来,我们将把预制件与我们的代码关联起来。最后,我们将结束于一个关于将所有内容组合在一起并运行组合项目的部分。

创建新项目和游戏对象

让我们首先创建一个新的项目和游戏对象:

  1. 在 Unity 中创建一个新的项目。将此项目命名为 The Body 并使用 2D 模板。图 7.25 – 以 The Body 命名的 Unity Hub 项目创建

    图 7.25 – 以 The Body 命名的 Unity Hub 项目创建

    重要提示

    在进行任何其他操作之前,请按照 第六章添加和操作 ink-Unity 集成插件 的说明,在新项目中安装 ink-Unity Integration 插件。

  2. 一旦 Unity 创建了项目,请向 Canvas 游戏对象添加一个 Canvas 游戏对象可以通过选择 Canvas 游戏对象访问,Unity 将自动添加一个 EventSystem 游戏对象。

  3. 点击 Canvas 游戏对象。在 检查器 视图中,点击 添加组件 按钮。选择 布局 然后选择 垂直布局组图 7.27 – 选择垂直布局组组件

    图 7.27 – 选择垂直布局组组件

    垂直布局组将自动以垂直模式在其内部对齐所有其他 UI 游戏对象。

  4. 在垂直布局组中,点击 子对齐 下拉菜单并选择 中间居中图 7.28 – 已选择中间居中的垂直布局组

    图 7.28 – 已选择中间居中的垂直布局组

  5. Text 游戏对象中选择 Canvas 游戏对象。Text 游戏对象可以在 Text 下找到,并将作为 Canvas 游戏对象的子对象添加。图 7.29 – 在层次结构视图中添加的 Text 游戏对象

    图 7.29 – 在层次结构视图中添加的 Text 游戏对象

  6. Button 游戏对象中选择 Canvas 游戏对象。Button 可以在 Button 游戏对象下找到,并将作为 Canvas 游戏对象的子对象添加。图 7.30 – 在层次结构视图中添加的 Button 游戏对象

    图 7.30 – 在层次结构视图中添加的 Button 游戏对象

  7. 选择新添加的 Button 游戏对象,然后从 项目 窗口中的 Button 拖动并点击。图 7.31 – 在项目窗口中创建的预制件

    图 7.31 – 在项目窗口中创建的预制件

  8. Button 游戏对象图标在 Button 游戏对象中的 Button 游戏对象更改后,现在它是一个预制件,它作为一个资产存在,不需要在当前的 层次结构 视图中存在。(它稍后将通过代码实例化。)

在创建项目和游戏对象后,下一项是一个 script 组件。这将创建运行故事所需的必要属性。

关联预制件和 ink JSON 文件

在上一节创建游戏对象后,我们现在将创建一个 script 组件,创建必要的属性,然后将资产与属性关联:

  1. 层次结构视图中选择Canvas游戏对象。

  2. 在使用创建脚本组件部分中的说明的script组件中。

  3. (或创建后重命名)此新文件为InkStory.cs。![图 7.32 – 创建的 InkStory.cs 文件 图片

    图 7.32 – 创建的 InkStory.cs 文件

  4. 双击InkStory.cs文件以在 Visual Studio 中进行编辑。

  5. 将代码更新为以下内容:

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using Ink.Runtime;
    public class InkStory : MonoBehaviour
    {
        public TextAsset InkJSONAsset;
        public GameObject prefabButton;
    }
    

Unity 提供的默认代码中新增了三项内容。第一项是包含Ink.Runtime命名空间,这将允许我们在故事运行时使用 ink。后两项新增内容是我们将在 C#中的public关键字中使用的属性,以创建可以在编辑器中调整的属性:

  1. 保存文件并返回 Unity。

  2. 创建一个名为(或创建后重命名)TheBody.ink的新 ink 文件。

  3. 在 Inky 中打开TheBody.ink文件进行编辑,并从 GitHub 的文件中复制内容。

    注意

    此示例的代码TheBody.ink可以在 GitHub 上找到。

  4. 保存 ink 源文件并返回 Unity。

    InkStory.cs中使用的public关键字为Canvas游戏对象添加了两个新属性。

    ![图 7.33 – 在检查器视图中添加的属性 图片

    图 7.33 – 在检查器视图中添加的属性

  5. 点击Ink JSON Asset属性旁边的文件选择,打开选择文本资产窗口。

  6. 将 ink-Unity Integration 插件创建的 ink JSON 文件与Ink JSON Asset属性关联,然后关闭选择文本资产窗口。

  7. 点击Button预制件旁边的文件选择,打开选择游戏对象窗口。

  8. 如果未打开,在选择游戏对象窗口中选择资产选项卡。

  9. 选择按钮预制件,然后关闭选择游戏对象窗口。

将 ink JSON 文件与Button预制件关联的结果是,代码在运行时可以访问这些资源。

![图 7.34 – 更新了 Ink Story 组件,包含 ink JSON 文件和按钮预制件值图片

图 7.34 – 更新了 Ink Story 组件,包含 ink JSON 文件和按钮预制件值

script组件的属性关联的文件,现在可以编写额外的代码。更改 ink 源文件TheBody.ink并保存更改将自动更新TheBody.json文件。同样,对于Button预制件也是如此。它也可以进行调整,并更改其设置。只要这两个资源没有重命名,Unity 将保持关联,并允许开发者在场景运行时独立于使用它们的代码自定义其设置。

到本节结束时,我们将创建一个 Unity 项目、其游戏对象以及相关的具有属性的资源。在我们能够运行项目之前,我们需要编写更多代码来根据正在运行的墨迹故事的内容动态创建用户界面。在下一节中,我们将编写代码来使用预制件并根据ContinueMaximally()方法和currentChoices属性的文字输出创建动态界面。

制作动态用户界面

在 Unity 项目可以运行之前,需要添加更多的代码。我们需要结合本章中解释的概念,包括使用ContinueMaximally()方法和currentChoices属性。我们还需要在代码中添加一个整体循环,使用canContinue属性检查在推进故事之前是否有更多内容。

我们首先在不会由 Unity 编辑器使用的类中添加我们将需要的属性。我们使用private关键字标记这些属性。

在 Visual Studio 中打开InkStory.cs进行编辑:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Ink.Runtime;
public class InkStory : MonoBehaviour
{
    public TextAsset InkJSONAsset;
    public GameObject prefabButton;
    private Story inkStory;
    private Text currentLinesText;
}

要与用户界面游戏对象一起工作,需要另一个using关键字的实例。这将为TextButton等在此文件中使用的类添加访问权限。

Story类和Text游戏对象currentLinesText将在本代码的多个方法中使用。为了确保它们可以以这种方式使用,它们必须是InkStory类的属性,而不是任何方法中的变量。

必须首先发生的是加载 ink JSON 文件。接下来,需要一个对Text组件的引用。每次用户做出选择时,都会显示文本。这意味着需要更新Text游戏对象的text属性。然而,由于它是Canvas的子项,需要使用GetComponentInChildren()方法:

void Start()
{
inkStory = new Story(InkJSONAsset.text);
currentLinesText = GetComponentInChildren<Text>();
}

加载文本内容和当前选项的过程将被多次使用。这意味着作为该过程一部分使用的所有代码都应该是一个独立的方法:

void LoadTextAndWeave()
{
if (inkStory.canContinue)
{
currentLinesText.text = inkStory.ContinueMaximally();
foreach (Choice c in inkStory.currentChoices)
{
GameObject cloneButtonGameObject = 
  Instantiate(prefabButton, this.transform);
Button cloneButtonButton =
  cloneButtonGameObject.GetComponent<Button>();
cloneButtonButton.onClick.AddListener(delegate
{
inkStory.ChooseChoiceIndex(c.index);
LoadTextAndWeave();
                });
Text cloneButtonText = cloneButtonButton.
  GetComponentInChildren<Text>();
cloneButtonText.text = c.text;
}
}
}

在新的LoadTextAndWeave()方法中,如果canContinue属性为真,将加载新的文本内容。使用foreach关键字,通过 Unity 中的Instantiate()方法添加新的按钮。这将在运行时将预制件作为 GameObject 实例化,通过代码创建它并将其添加到正在运行的场景中。

最后,使用 Unity 中按钮的OnClick属性和AddListener()方法。这会将一个集合添加到其中,指定哪些函数应该在点击发生时被通知。delegate关键字允许开发人员将方法作为参数传递给另一个方法。在这种情况下,在foreach循环的同一作用域内创建了一个简短的方法。因此,可以在创建的方法中使用index属性。

每次按钮被点击时,Story类方法ChooseChoiceIndex()将使用正确的索引被调用,并且LoadTextAndWeave()方法将再次被调用,刷新currentLinesText方法的值并更新屏幕上显示的当前按钮:

  1. 要运行当前代码,还需要进行一项更改。需要在Start()方法中调用LoadTextAndWeave()方法:

    void Start()
    {
    inkStory = new Story(InkJSONAsset.text);
    currentLinesText = GetComponentInChildren<Text>();
    LoadTextAndWeave();
    }
    
  2. 在 Visual Studio 中保存当前代码。返回 Unity 并运行场景。

    立即,两个问题将变得明显。首先,默认的黑色文本在深色背景上使得文本难以阅读。其次,只显示前几个单词。

    图 7.35 – 在 Unity 中运行的 Body 项目

    图 7.35 – 在 Unity 中运行的 Body 项目

  3. 单击(继续。)按钮以查看更多两个问题。

图 7.36 – 在 The Body 中动态创建的按钮

图 7.36 – 在 The Body 中动态创建的按钮

第一个问题是在替换第一个按钮而不是添加两个按钮。这是由于对LoadTextAndWeave()方法的第二次内部调用引起的。首先,加载了文本内容和按钮。然后,当(继续。)按钮被点击时,它再次被调用,添加了更多按钮。

我们还可以观察到按钮很小,难以阅读。默认情况下,Unity 将为Button游戏对象假设一些值。在调整我们的代码时,我们还需要更改属性:

  1. 停止运行场景。

  2. 要开始修复文本问题,首先,在16030中选择Text游戏对象。

  3. 通过点击和拖动使用800宽度和300高度。![图 7.37 – Unity 中带有更新后的宽度和高度值的检查器视图 图 7.37 – Unity 中带有更新后的宽度和高度值的检查器视图

    图 7.37 – Unity 中带有更新后的宽度和高度值的检查器视图

  4. 单击1424。这将使起始大小更大。

  5. 单击default将其更改为white,然后关闭颜色窗口。

    更新后的值现在将显示更多文本,并且由于白色背景较暗,其可读性有所提高。

    ![图 7.38 – 更新后的 Text GameObject 组件值 图 7.38 – 更新后的 button Prefab 值

    图 7.38 – 更新后的 Text GameObject 组件值

  6. 项目窗口中单击ButtonPrefab。

  7. Text游戏对象一样,其默认宽度为160,高度为30。将宽度更改为250,高度更改为100。![图 7.39 – 更新后的 button Prefab 值 图 7.39 – 更新后的 button Prefab 值

    图 7.39 – 更新后的 button Prefab 值

  8. 返回到 Visual Studio 中的InkStory.cs进行编辑。

代码的修复是一个小但重要的更改。每次按钮被点击时,代码将需要销毁当前按钮并创建新的按钮:

  1. 需要一种新方法来执行特定任务,即销毁Button子对象:

    DestroyButtonChildren() method needs to be called as part of the delegate method. Before the content is refreshed, the current buttons need to be destroyed:
    
    

    cloneButtonButton.onClick.AddListener(delegate

    {

    inkStory.ChooseChoiceIndex(c.index);

    DestoryButtonChildren();

    LoadTextAndWeave();

    });

    
    
  2. 在 Visual Studio 中保存更新后的文件并返回 Unity。

    DestroyButtonChildren()方法查找特定的标签值。这需要添加到按钮预制件中。

  3. 项目窗口中选择按钮预制件。

  4. 检查器视图中,点击标签下拉菜单,然后选择添加标签…选项。图 7.40 – Unity 检查器视图中的标签下拉菜单

    图 7.40 – Unity 检查器视图中的标签下拉菜单

  5. 点击+图标向列表中添加一个新的标签。在提示中,使用名称ButtonChoice。![图 7.41 – 新标签名称 图片

    图 7.41 – 新标签名称

  6. 点击保存以创建一个新的标签。

  7. 点击已添加到ButtonChoice标签中的按钮预制件,必须选择它。

  8. 标签下拉菜单中,选择ButtonChoice。![图 7.42 – 将 ButtonChoice 选项添加到标签下拉菜单 图片

    图 7.42 – 将 ButtonChoice 选项添加到标签下拉菜单

  9. 运行场景。通过点击按钮进行选择并查看结果来玩故事。

  10. 玩完故事后,停止运行场景。

文本游戏对象和代码的更改将加载新的文本,并在玩家点击按钮时正确更新选择。虽然步骤较多,但这种方法可以用于大多数 ink JSON 文件,以向玩家展示文本和动态按钮,让他们做出不同的选择,并在屏幕上看到结果。

摘要

在本章中,我们探讨了添加脚本组件、将 ink JSON 文件与属性关联以及使用Story类中的方法和属性来推进运行中的 ink 故事的过程。我们看到了Continue()方法一次加载一行,而ContinueMaximally()方法则加载所有文本直到遇到编织点。当与canContinue属性结合使用时,这些方法允许从 ink JSON 文件中加载文本内容,并在内容耗尽时防止出现任何错误。通过currentChoices属性,我们探讨了如何使用循环,例如使用foreach关键字。当我们使用ChooseChoiceIndex()方法时,我们选择了想要从编织点中选择的选项,并再次使用Continue()ContinueMaximally()方法推进故事。

通过在 Unity 中设置用户界面游戏对象,我们构建了一个动态过程来加载墨水故事内容,销毁按钮,然后创建新的按钮。需要创建一个按钮预制件时,我们看到了代码在运行时如何实例化这些预制件。调整文本按钮游戏对象的值,我们完成了一个运行 ink JSON 文件的界面,并构建了一个许多其他项目都可以使用的系统,这些项目使用相同的游戏对象和组织。

在下一章中,我们继续使用Story类及其方法。我们将探讨如何使用 C#代码检索和更新墨迹故事中的变量值。我们还将了解如何访问 ink 中的函数以及如何向它们传递和接收数据。结合用户界面游戏对象,我们将构建一个示例,展示如何通过使用 ink 中的内容在 Unity 中创建多个动态界面,从而实现 ink 运行时与 Unity 代码之间的通信。

问题

  1. Story类中的Continue()ContinueMaximally()方法之间的区别是什么?

  2. Story类中的ChooseChoiceIndex()方法期望哪种类型的数据?

  3. Story类中,如何使用canContinue属性与Continue()ContinueMaximally()方法一起使用?

  4. Unity 中的 Prefab 是什么?

  5. Story类的currentChoices列表属性中可以找到哪种类型的对象?

第八章:第八章:故事 API – 访问墨水变量和函数

在本章中,我们将讨论如何使用 ink Unity API 来处理变量和函数。ink 中定义的任何变量或函数都可以从其代码的任何位置访问。ink-Unity 集成插件提供的 API 通过其variablesState属性提供了一个接口,用于访问任何定义的变量。这也适用于由EvaluateFunction() API 提供的方法,它可以访问 ink 代码中定义的任何函数。理解这一功能是使用 ink-Unity 集成插件作为 ink 故事和 Unity 代码之间桥梁来创建更复杂项目的关键。

在本章中,我们将涵盖以下主要主题:

  • 在故事之外更改墨水变量

  • 外部调用墨水函数

  • 通过变量和函数控制故事

技术要求

本章中使用的示例,在*.cs*.ink文件中,可以在 GitHub 上找到:github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language/tree/main/Chapter8

在故事之外更改墨水变量

VAR关键字和初始值。在整个故事中,变量的值可以被更改。通过比较它们的值,变量也可以影响故事的流程。

在 ink 中,变量是全局的。一旦创建,它们可以被同一故事内的代码的任何其他部分访问。此功能也通过 ink-Unity 集成插件的一部分名为variablesState的命名属性传递,该插件称为variablesState。Ink 故事中定义的每个变量都可以通过其名称访问。

在这个主题中,我们将探讨如何使用这个属性来访问和更改运行中的 ink 故事的值。我们将首先查看如何使用variablesState属性,并在 ink 中比较值以控制其故事外的流程。

访问墨水变量

Unity 中的故事 API 提供了对 ink 变量的访问。在本节中,我们将探讨variablesState属性以及如何通过名称访问 ink 变量。执行以下步骤:

  1. 首先在 Unity 中基于 2D 内置模板创建一个新项目。

  2. 导入 ink-Unity 集成插件。

  3. 添加一个新的 ink 文件,并将其重命名为InkVariables.ink

    创建的文件将包含 ink 源代码。因为 ink-Unity 集成插件运行编译后的 ink 故事,所以必须在 API 可以使用之前存在故事的源代码:

    图 8.1 – 显示 InkVariables.ink 文件的工程窗口

    图 8.1 – 显示 InkVariables.ink 文件的工程窗口

  4. 在 Inky 中打开InkVariables.ink文件进行编辑。

  5. 将内容更改为Example 1 (InkVariables.ink)文件。

  6. 保存文件并返回 Unity。

    提醒

    可以通过访问 项目设置 窗口来更改 ink 源文件的自动重新编译。您可以通过点击 编辑 然后点击 项目设置 来完成此操作。点击 Ink 然后更改 自动编译所有 Ink 设置可以更新此值。如果启用,插件将自动创建一个 JSON 文件。

  7. 创建一个新的空游戏对象,并将其命名为 InkStory

  8. InkStory 游戏对象中创建一个新的 Script 组件,并将新的 C# 文件命名为 InkStoryScript.cs

  9. 在 Visual Studio 中打开 InkStoryScript.cs 文件进行编辑。

  10. InkStoryScript.cs 文件更新为 示例 1 (InkStoryScript.cs)

    更新后的代码使用 GetVariableWithName() 方法作为 VariablesState 对象的一部分。variablesState 属性与 ink 中的变量名称 number_example 一起使用,作为 GetVariableWithName() 方法的一部分。此外,Debug.Log() 方法用于在 Unity 中运行时在 控制台 窗口中显示变量的值。

  11. 将文件与代码关联起来。您可以使用 Script 组件中的 Ink JSON File 属性来完成此操作。

  12. 在 Unity 中运行项目。

    一旦项目开始运行,打开 控制台 窗口。将添加一条新消息:

    图 8.2 – 显示 ink 变量值的控制台窗口

    图 8.2 – 显示 ink 变量值的控制台窗口

  13. 在 Unity 中停止运行的项目。

如果在 ink 的第一个文本内容之前定义了变量,它们也会在内容之前被加载。在 示例 1 中,正如 步骤 8 所示,变量的初始值可以在加载 ink 故事后立即访问。访问变量的功能与加载和显示文本内容的功能是分开的。

任何可以访问的 ink 变量也可以被更改。在下一节中,我们将发现这种功能如何是使用 Ink API 中的 variablesState 属性的关键,以及它如何允许你在使用 ink-Unity Integration 插件时在 Unity 中创建更复杂的项目。

更改 ink 变量的值

英语单词 "variable" 的意思是 可以更改的。一旦创建,所有 ink 变量都可以在任何时候更改。variablesState 属性遵循相同的模式。如果一个变量可以被访问,它的值可以被更改。

VariablesState 类包含多个方法,可以用来访问和更改 ink 故事中变量的值。然而,它还包含一个使用方括号和变量名称的引号表示法的简写访问运算符。通常,这种简写比直接使用方法名称来更改 variablesState 属性中变量的值更常用。

现在,返回到我们在 访问 Ink 变量 部分使用过的项目。执行以下步骤:

  1. 在 Visual Studio 中打开 InkStoryScript.cs 文件进行编辑。

  2. 将现有的代码更改为 示例 2 (InkStoryScript.cs)

  3. 保存文件并返回 Unity。

  4. 运行项目。

    variablesState 属性。对这些值的任何更改都会反映在下次使用 Continue()ContinueMaximally() 方法时。

  5. 在 Unity 中停止运行的项目。

变量不是可以从 ink 故事外部访问的唯一值。ink-Unity Integration 插件还增加了从 Unity 调用 ink 中函数的能力。虽然访问和更改变量的值可能很有帮助,但直接在 ink 中调用函数并将值从 Unity 传递给它们通常是处理 Unity 和 ink 之间数据交换的首选方法。在下一节中,我们将回顾为什么使用函数通常是处理复杂数据或当您希望在 ink 中作为同一任务的一部分处理多个值时的更好选择。

外部调用 ink 函数

与变量一样,ink 中的函数也是全局的。这意味着它们可以作为同一故事中 ink 代码的一部分被访问。作为 Story 类提供的 Unity API 的一部分,EvaluateFunction() 方法根据传递给它的名称在 ink 代码中调用函数。由于 ink 中的函数是全局的,因此可以从故事外部调用它们。然而,与仅访问单个值的 variablesState 属性不同,一次可以向 ink 函数传递多个值。此外,EvaluateFunction() 方法还可以配置为在 ink 函数内部返回文本输出或任何返回的数据。

在本节中,我们将首先使用 HasFunction() 方法测试 ink 函数是否存在。接下来,我们将探讨 EvaluateFunction() 方法为何是 Unity 和 ink 之间通信时处理复杂数据或多个数据值的优选选项。最后,我们将回顾如何在 Unity 代码中使用 ink 函数的文本结果和返回数据。

验证和评估 ink 函数

当在 ink 中使用函数时,HasFunction() 方法会验证 ink 函数是否存在。请注意,在处理 ink 函数之前始终应使用它,以防止出现任何问题:

  1. 在 Unity 中基于内置的 2D 模板创建一个新的项目。

  2. 导入 ink-Unity Integration 插件。

  3. 创建一个新的空游戏对象,命名为 InkStory

  4. Script 组件添加到 InkStory 游戏对象中,并将墨迹文件命名为 InkStoryFunctions.cs

  5. 添加一个新的墨迹文件,并将其重命名为 InkFunctions.ink

  6. 在 Inky 中打开 InkFunctions.ink 文件,并将其内容更改为 示例 4 (InkFunctions.ink)

  7. 在 Visual Studio 中打开 InkStoryFunctions.cs 进行编辑。

  8. InkStoryFunctions.cs 文件更新为 示例 4 (InkStoryFunctions.cs)

  9. 保存 InkStoryFunctions.cs 文件。

  10. 在 Unity 中,将 InkFunctions.ink 文件的编译 JSON 文件与 InkStory 游戏对象的 Script 组件中的 Ink JSON File 属性关联起来。

  11. 运行项目。

    由于调用了两个方法HasFunction()EvaluateFunction()relationship ink 变量已被更改。Story类的HasFunction()方法返回一个布尔值。它还保证了在使用之前函数存在。

  12. 停止项目。

在验证函数存在后,可以对其进行评估。InkStoryFunctions.cs文件中的代码使用两个方法:HasFunction()EvaluateFunction()。在 ink API 中,术语EvaluateFunction()方法。

在使用EvaluateFunction()方法时,第一个参数是 ink 中函数的名称。任何其他参数都直接传递给 ink 函数。在作为InkStoryFunctions.cs文件一部分使用的代码中,EvaluateFunction()方法的用法包括两个参数:increase() ink 函数的名称以及 ink 中要增加的值量。

当使用 ink-Unity 集成插件时,从 Unity 调用墨水功能来更改值是一种非常常见的模式。在这种情况下,ink 中的increase()函数更新 ink 内部的relationship值。这允许 ink 值通过 ink 函数进行调整。在 Unity 中工作时,可以根据为这些任务定义的 ink 函数,将值传递给 ink 以执行多个任务,而无需在 Unity 侧添加额外代码。

ink 函数是故事的特殊部分。这意味着它们还可以与其他与代码相关的操作一起产生文本输出。但是,要使用EvaluateFunction()方法获取 ink 函数的文本输出,需要在 C#中使用一个特殊的关键字:out。我们将在下一节中了解更多关于这个关键字的信息。

获取 ink 函数文本输出

C#编程语言允许您定义一个变量,然后将其传递给一个方法。在方法内部,预期变量的值将发生变化。请注意,这不会是传递给方法的值的变化,而是变量本身包含的值的变化。更普遍地说,在编程中,这被称为通过引用传递。不是将一些数据传递给方法,而是传递一个引用(即找到变量以存储值的位置)。

在 C#中,可以使用out关键字与方法的参数一起使用,以指定变量(而不是其值)应该通过引用传递。这意味着当方法完成其操作后,传递给方法的变量中的值将发生变化。在 C#中,使用out关键字允许开发人员指定他们想要从方法中获取值并将其放入特定变量中。

当使用 ink-Unity 集成插件提供的EvaluateFunction()方法时,如果第二个参数使用out关键字,该方法知道在评估过程中产生的任何文本都应该传递方法并返回到变量。执行以下步骤:

  1. 返回到验证和评估 ink 函数部分中使用的代码。

  2. InkStoryFunctions.cs文件中的代码更新为示例 5 (InkStoryFunctions.cs)

  3. 保存更改后的InkStoryFunctions.cs文件。

  4. 返回 Unity。

  5. 播放项目。

  6. 停止项目。

使用out关键字将functionOut变量作为EvaluateFunction()方法的参数,让 C#知道将任何文本输出到方法之外。因为relationship值是通过从 Unity 调用 ink 中的increase()函数内部更新的,所以它的值在 C#中的functionOut变量中显示为51。这允许它然后将值传递给Debug.Log()方法,并最终在控制台窗口中显示更新后的值。

当以这种方式使用out关键字与EvaluateFunction()方法时,任何从函数输出的文本都可以被捕获并从 ink 传递回 Unity。结合创建 ink 函数来更新 ink 值,这种对验证和评估 ink 函数部分中显示模式的额外更改允许你调用 ink 函数来执行 ink 相关任务。这允许 ink 关注点与 Unity 中的关注点分离。

在 ink 中,故事可以通过不同的值来控制。使用条件选项和选择性输出,可以选择显示选择文本或跟随分支。因为可以通过variablesState属性和EvaluateFunction()方法调用的函数直接访问变量值,这意味着 ink 故事可以从 Unity 中控制。在下一节中,我们将学习如何将 Unity 中的用户界面元素与 ink 变量和函数连接起来。

通过变量和函数控制故事

variablesState属性和EvaluateFunction()方法为开发者提供了两种访问 ink 故事中值的方式。通过使用这两种方法,故事可以通过向玩家展示的选项之外的更多方式来控制。Unity 中的用户界面元素可以附加到可以更改 ink 值的方法。

在本节中,我们将连接 ink 到 Unity。通过使用variablesState属性和EvaluateFunction()方法,我们将回顾一个代码模式,其中 Unity 提供用户界面并与 ink 函数通信,以在运行时调整和响应值。

在本主题的三个部分中,首先,我们将通过创建必要的游戏对象来准备一个 Unity 项目。接下来,我们将添加代码来控制用户界面。最后,我们将调整用户界面的展示并运行项目。

准备用户界面

要开始使用 Unity 按钮,需要一个新项目。为了简单起见,建议使用 2D 项目。这将允许你轻松地与界面一起工作,而无需担心透视。执行以下步骤:

  1. 使用内置的 2D 模板在 Unity 中创建一个新项目,并将此项目命名为购物之旅

  2. 导入 ink-Unity 集成插件。

  3. 创建一个名为InkStory的新空游戏对象。

  4. InkStory游戏对象内部创建一个新的Script组件。将创建的 C# 文件命名为InkStoryShopping.cs

  5. 创建一个名为InkShopping.ink的新墨水文件。

  6. 在 Inky 中打开InkShopping.ink文件进行编辑,并将其内容更改为示例 6 (InkShopping.ink)

    在 Unity 中创建一个新的Button游戏对象。选择 Unity 中自动创建的Canvas游戏对象并创建第二个Button游戏对象,以便两个按钮都是Canvas游戏对象的子对象:

    ![图 8.4 – 在 Unity 中创建的按钮

    ![图 8.4_B17597.jpg]

    图 8.4 – 在 Unity 中创建的按钮

  7. 再次选择Canvas游戏对象并创建一个Text游戏对象。两个按钮以及新创建的Text游戏对象都应该是Canvas游戏对象的子对象。

作为步骤 6部分创建的墨水文件建立了未来从 Unity 代码中调用的墨水功能。这两个按钮将作为买卖被墨水代码跟踪的库存的接口。在下一节中,我们将从设置好一切过渡到编写 Unity 代码以在接口和墨水之间创建桥梁。这将跟踪两个值:moneyinventory

脚本化用户界面对象

在上一节中,我们完成了创建新的 Unity 2D 项目和创建必要的游戏对象的步骤。在本节中,我们将学习如何通过在 Unity 侧添加代码将用户操作(即点击)与用户界面连接起来。然后,我们将使用 Unity 中的EvaluateFunction()方法与运行中的故事中的墨水功能进行通信以控制其进度:

  1. 在 Visual Studio 中打开InkStoryShopping.cs文件进行编辑。

  2. 将其内容更改为示例 6 (InkStoryShopping.cs)

    在新代码中,Unity 代码中已添加了三个新方法。我们已经直接将 Unity 方法映射到墨水功能。例如,Unity 中Sell()方法的名称几乎与墨水功能的sell()名称匹配。大小写差异仅是因为每个编程环境中推荐的命名约定。

  3. 将编译后的 ink JSON 文件与新的公共属性关联。将Text游戏对象与Text Status属性关联:![图 8.5 – 将 Text GameObject 与 Text Status 属性关联

    ![图 8.5 – Figure_8.5_B17597.jpg]

    图 8.5 – 将 Text GameObject 与 Text Status 属性关联

    建议

    由于两个现有的Button游戏对象有子Text游戏对象,建议您使用拖放方法将游戏对象与属性关联。这将防止关联错误的Text游戏对象。

  4. 在 Unity 中选择Canvas游戏对象中的水平布局组组件。

  5. 水平布局组组件从默认的水平布局组组件更改为,为Canvas游戏对象添加布局结构。调整子对齐属性会改变所有子对象的起始位置,使其位于可用屏幕空间的绝对中心位置。

  6. 选择Canvas游戏对象的子游戏对象中的第一个按钮游戏对象,在按钮游戏对象中可以关联一个或多个与它的OnClick用户事件相关的监听函数。当用户点击按钮游戏对象时,这些函数将按照它们在列表中出现的顺序被调用。

    因为按钮是一个GameObject,它只能与其他游戏对象通信。这是 Unity 理解游戏对象和它们组件之间差异的重要方面。一个GameObject是其他组件的容器,包括任何脚本组件。这意味着要将按钮游戏对象的OnClick用户事件连接到某个脚本组件中找到的代码,必须使用与该脚本组件关联的GameObject。对我们来说,这意味着InkStory游戏对象。

  7. InkStory游戏对象与第一个按钮游戏对象的On Click ()组件关联起来。这可以通过将InkStory游戏对象拖放到属性上完成:![图 8.8 – 关联 InkStory GameObject 与 On Click () 组件 图片

    图 8.8 – 关联 InkStory GameObject 与 On Click () 组件

    一旦一个GameObjectOn Click ()组件列表中的条目关联,无函数下拉菜单将被启用。在此关联之后,Unity 将处理游戏对象并查找可能使用的每个可能的方法或函数。

  8. 使用InkStoryShopping.Buy

  9. 选择Canvas游戏对象的子对象中的第二个按钮游戏对象,在On Click ()组件中。

  10. 按照步骤 7步骤 8InkStory游戏对象与On Click``()组件关联起来。对于步骤 8,而不是使用Buy``()方法,选择Sell``()方法:

![图 8.9 – 关联 InkStoryShopping.Sell() 方法图片

图 8.9 – 关联 InkStoryShopping.Sell() 方法

步骤 10结束时,第一个按钮游戏对象关联到Buy()方法,第二个关联到Sell()方法。内部,这些方法正在与它们对应的墨迹函数进行通信。正如你很快会发现的那样,点击按钮将调用 Unity 方法,而这些方法反过来又会调用墨迹函数。

调整展示值

在本节的最后,我们将更改之前创建的用户界面游戏对象的默认值。调整这些值将帮助我们更好地理解游戏对象之间的关系,并改善与屏幕按钮交互的体验:

  1. 选择Text游戏对象中的第一个Button游戏对象。

  2. Text属性值从默认设置Button更改为Buy:![图 8.10 – 更改 Text 属性 图片

    图 8.10 – 更改 Text 属性

  3. 选择展示的Text游戏对象中的第二个Button游戏对象。

  4. 将第二个Button游戏对象的默认文本从Button更改为Sell

  5. 选择Canvas游戏对象的第三个子游戏对象,即Text游戏对象。确保不要选择之前步骤中更新的前两个Text游戏对象。

  6. Text游戏对象的宽度和高度更改为400250:![图 8.11 – 调整 Text 游戏对象的宽度和高度 图片

    图 8.11 – 调整 Text 游戏对象的宽度和高度

  7. Text游戏对象的字体大小从默认值更改为32

  8. Text游戏对象的颜色从默认设置更改为白色或接近白色的颜色:![图 8.12 – 更新 Text 游戏对象的颜色 图片

    图 8.12 – 更新 Text 游戏对象的颜色

  9. 播放项目。

    播放时,最左侧将显示墨迹中moneyinventory变量的更新状态。点击按钮将调用 Unity 方法,这些方法反过来将评估墨迹函数并更改运行中的墨迹故事中的值。这是一个如何通过变量和函数控制墨迹故事的完整示例。

  10. 停止项目。

虽然创建用户界面元素和更改其属性值涉及多个步骤,但代码相对简单。在墨迹中创建了函数来调整墨迹值。在 Unity 中,创建了与墨迹函数名称匹配的方法。

本节演示了如何在墨迹中创建一个简单的购物场景,并从 Unity 中进行操作。通过了解墨迹函数的名称,Unity 中的 C#方法可以评估它们以调整值,或者在status()墨迹函数的情况下,检索文本输出。这还演示了如何将用户界面编程与与故事相关的代码分离。它们相互通信,但它们是在不同的上下文中编写的。

在下一章中,我们将探讨在故事中处理变量和函数的不同方法,同时继续我们分离叙事和游戏代码的趋势。然而,我们不会在 Unity 中点击按钮来触发 ink 中的函数,而是探索相反的方法。事件将在 ink 中发生并触发 Unity 中的变化。本章重点介绍了如何从 Unity 控制 ink。下一章将演示如何从运行中的 ink 故事中的事件控制 Unity 的某些部分。

摘要

在本章中,我们首先演示了如何通过GetVariableWithName()方法使用名称访问变量以及使用方括号提供的简写语法。为了完整性,解释了variablesState属性。然而,在大多数情况下,ink 函数应该改变 ink 的值。这有助于保持任何与这些值一起工作的代码存在于 ink 故事中,并且随着时间的推移更容易维护,我们以这个主题结束了本章。此外,我们还探讨了 Unity 中的按钮如何调用它们的方法,然后调用 ink 函数。通过使用EvaluateFunction()方法,我们可以访问 Unity 中的 ink 函数,要么将数据传递到项目中,要么使用 C#中的out关键字检索可能的文本输出。

第九章“Story API – 观察和响应故事事件”中,我们将通过检查 Unity 和 ink 之间关系的一种不同方法来强调 ink-Unity 集成插件及其 API。我们不会使用 Unity 方法来调用 ink 函数,而是检查一些从 ink 控制 Unity 部分的模式。我们不需要在 Unity 中点击按钮来更改值,ink 将导致变化,然后将在 Unity 中注册。对于需要从 ink 获得更多实时反馈的项目,这些模式将是本章中所示使用variablesState属性和EvaluateFunction()方法所示方法的优选方法。

问题

  1. ink 中的变量是否是全局的?

  2. 函数全局化对它们在 ink 中如何访问有什么影响?

  3. Continue()ContinueMaximally()方法是否会影响 ink 中变量的值?

  4. VariablesState类提供了什么简写语法来根据名称访问变量?

  5. 在尝试访问之前,是否应该使用 ink 函数的名称来测试它是否存在?

  6. 在使用 ink-Unity 集成插件时,out C#关键字是如何与EvaluateFunction()方法一起作为 Story API 的一部分使用的?

第九章:第九章:故事 API – 观察和响应故事事件

在本章中,我们将探索运行中的 ink 故事中的变化如何触发 Unity 中的事件。我们将学习 ink-Unity 集成插件提供的 Story API 中的ObserveVariable()ObserveVariables()方法如何允许您准备函数以响应 Unity 中的未来事件。我们将从观察单个变量开始,然后继续学习如何观察多个值。

第八章故事 API – 访问 ink 变量和函数中,重点是通过对 ink 故事调用其函数和从 Unity 更改其值来控制 ink 故事。本章将这两个系统之间的重点进行了反转。在本章中,我们将探索如何使用叙事事件,例如由于玩家的选择而更改的变量,来控制 Unity 中呈现的信息。

在本章中,我们将涵盖以下主题:

  • 监听变量变化

  • 动态响应 ink 故事

  • 观察多个 ink 值

技术要求

本章中使用的示例,在*.ink文件中,可以在 GitHub 上找到:github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language/tree/main/Chapter9

监听变量变化

ink 中的变量是全局的。一旦创建,它们可以在故事的任何位置访问。在第第八章故事 API – 访问 ink 变量和函数中,我们学习了如何使用variablesState属性来访问或更改它们的值。然而,我们不仅可以直接在 Unity 中干预正在运行的 ink,我们还可以等待 ink 中发生某些事情,然后在 Unity 中做出反应。这种类型的方法所使用的动词,作为故事 API 的一部分,被称为观察

当我们观察 ink 变量时,我们可以编写自己的规则,关于当其值发生变化或达到某个阈值时应该发生什么。我们只是在观察其值。由于这种观察,我们做什么取决于开发者。

在这个主题中,我们将探索ObserveVariable()方法。

推荐

建议您为此主题创建一个新的 Unity 2D 项目。有关如何创建新的 Unity 项目以及导入 ink-Unity 集成插件的说明,请参阅第六章添加和使用 ink Unity 插件

我们将执行以下步骤:

  1. 在一个新的 Unity 项目中,使用带有 ink-Unity 集成插件的 2D 模板,创建一个新的空游戏对象,并将其命名为InkStory。此游戏对象将包含script组件,并响应 Ink 代码中的变化。

  2. 创建一个新的 Ink 文件,并将其命名为InkStoryStepCounter.ink

  3. 在 Inky 中打开InkStoryStepCounter.ink进行编辑,并更新其内容为示例 1 (InkStoryStepCounter.ink)

  4. InkStory游戏对象内部创建一个新的script组件。创建的文件命名为InkStoryScript.cs

  5. 在 Visual Studio 中打开InkStoryScript.cs进行编辑。

    InkStoryScript.cs更新为示例 1 (InkStoryScript.cs)

    ObserveVariable()方法是在本章中引入的新方法,接受两个参数。第一个参数是要观察的变量的名称,第二个是要调用的函数或方法。

    本例中使用的代码还结合了一个名为ObserveVariable()的 C#概念,该方法在 ink 中观察一个变量。如果其值在任何时候发生变化,lambda 表达式将运行。这发生在正常流程之外。

  6. 关联编译后的 Ink JSON 文件。

  7. 运行项目。

当项目运行时,ObserveVariable()方法和第三个将是作为第一个选项产生的文本:

图 9.1 – 在 Unity 中按执行顺序显示文本输出

图 9.1 – 在 Unity 中按执行顺序显示文本输出

第二条消息在选项的第三个文本之前显示值的原因是因为执行顺序。在运行中的 ink 故事中,ObserveVariable()方法发生在文本输出为选项生成并返回 Unity 之前。这种方式下,委托的 lambda 表达式出现在正常执行流程之外。每当观察变量的值发生变化时,函数会立即被调用,无论此时周围是否有其他代码正在执行。

在下一节中,我们将在此基础上进行构建。通常,在 Unity 中,只有在变量发生变化时才应该通知 Unity,这样可以释放执行时间,让 Unity 执行其他任务,并允许开发者编写更响应式的代码,只有在需要时才运行。

动态响应 ink 故事

在 Unity 中,当项目运行时,作为正常执行周期的一部分,会调用多个方法。通常,如Update()这样的方法,是 Unity 中行为脚本的一个常见部分,包含许多行代码。甚至像FixedUpdate()这样的方法,在每个运行项目中的物理计算周期结束时被调用,也可能包含多个部分。任何依赖于其他系统(如与 ink 通信的系统)的代码也可以在每个周期中增加额外的时间。

使用ObserveVariable()方法允许 ink 的数据只在需要时更新 Unity。因为 Story API 只有在必要时才会调用委托函数,所以 Unity 也只有在需要知道变化时才会获取数据。这也会发生在 Unity 中Update()方法或FixedUpdate()方法之外的使用。

在本节中,我们将检查ObserveVariable()方法如何在 Unity 外部作为其他方法的一部分运行。它只会在值发生变化时调用委托函数,从而允许 Unity 中动态响应。

返回上一节创建的项目,并执行以下步骤:

  1. InkStoryScript.cs文件更新为示例 2 (InkStoryScript.cs)

  2. Update()方法中,正在进行四个不同的动作。

    第一个是变量时间的增加,使用最新的Time.deltaTime,这是在十进制(float)数字中测量的循环之间的毫秒数。第二个是将其浮点值转换为整数。这个操作移除了数字的小数部分。第三个动作是一个称为%的数学运算,可以用来找到除法的余数。这个操作称为模运算。然而,许多编程语言也使用术语余数运算符。当这个操作执行时,它将确定一个数字可以被另一个数字除多少次。在这种情况下,使用60的余数,seconds变量将始终等于自项目开始以来经过的秒数,除以time变量。

    Update()方法中的第四个动作是将seconds变量赋值为秒数,这是由之前解释的动作定义的。在 Unity 的每个循环中,这个数字都会更新,seconds变量将始终保持最新。

    在委托函数中发生最后一个动作,即使用Destroy()方法。在代码中,一旦stepsink 变量的值等于由 Unity 确定的3,它将从一个场景中移除一个按钮。这有助于保持按钮与 Unity 外部更改的值之间的连接。一旦 ink 变量更改并被报告给 Unity,按钮就会被移除。

    Start()方法的最后一行,为按钮的onClick事件提供了一个监听函数。当按钮被点击时,与监听器关联的任何函数都将被调用。在这个例子中,点击按钮将调用新的TakeStep()方法。这将加载下一个文本内容,直到遇到 ink 代码中的下一个编织点,然后选择编织中的第一个(0)选项。这将导致 ink 代码内部循环。

    在创建代码后,还需要两个步骤才能播放项目。首先,需要向项目中添加一个新的Button游戏对象。然后,一旦Button游戏对象存在,它必须与新代码中的InkStory属性相关联。

  3. 在 Unity 中创建一个新的Button游戏对象。

  4. Button游戏对象与Button Step属性关联。

  5. 播放项目。

  6. 创建的Button游戏对象出现在场景底部。点击Button游戏对象四次将导致其消失,并在控制台窗口中显示一条消息:图 9.2 – 由委托函数生成的控制台窗口中的消息

    图 9.2 – 由委托函数生成的控制台窗口中的消息

  7. 停止项目。

当项目首次启动时,代码的Update()方法在每个周期中都会被调用。在内部,它更新 Unity 代码中的timeseconds变量。每当点击Button游戏对象时,它就会推进墨迹代码,该代码会内部循环。由于使用了ObserveVariable()方法,每当墨迹变量步骤更新时,它都会调用委托函数并测试传递给它的新值。一旦达到3(基于总共四次点击将其从0移动到3),委托函数就在Button游戏对象中创建了一条消息。

本节中使用的示例遵循一个常见的模式,其中 Unity 在方法(如Update())中执行自己的计算,并动态响应墨迹故事的变化。而不是在每个周期中检查steps墨迹变量是否作为variablesState属性的一部分发生变化,如果值没有变化,则会浪费时间,委托函数允许 Unity 仅在需要时采取行动。对于更复杂的项目,这是首选的方法,并且通常会产生更快的项目。

在墨迹中可以观察到多个变量。根据设计的复杂性,Unity 项目可能对观察多个墨迹值并更新屏幕区域以显示故事进展或玩家当前统计数据感兴趣。在这些情况下,需要不同的方法:ObserveVariables()。在下一节中,我们将演示如何使用此方法。

观察多个墨迹值

ObserveVariable()方法并列的是另一个名为ObserveVariables()的姐妹方法。然而,虽然ObserveVariable()方法接受变量名和一个委托函数,但ObserveVariables()方法接受一个IList<string>类型的变量名列表和一个委托函数。它的委托函数在传递给方法的列表中的任何变量发生变化时被调用,而不是响应单个变量的变化。虽然设置起来稍微复杂一些,但ObserveVariables()方法提供了观察多个墨迹变量的功能。

建议

建议您为这一节创建一个新的 Unity 2D 项目。有关如何创建新的 Unity 项目以及导入 ink-Unity 集成插件的说明,请参阅第六章添加并使用 ink-Unity 集成插件

执行以下步骤:

  1. 在导入 ink-Unity Integration 插件的新的 Unity 2D 项目中,创建一个新的空游戏对象并将其命名为 InkStory。这个游戏对象将包含 Script 组件,并能够对 ink 代码中的任何更改做出反应。

  2. 创建一个新的 ink 文件,并将其命名为 InkStoryPlayerStatistics.ink

  3. 在 Inky 中打开 InkStoryPlayerStatistics.ink 文件进行编辑,并将其内容更新为 Example 3 (InkStoryPlayerStatistics.ink)

  4. InkStory 游戏对象内部创建一个新的 script 组件。将创建的文件命名为 InkStoryPlayerStatisticsScript.cs

  5. 在 Visual Studio 中打开创建的 InkStoryPlayerStatisticsScript.cs 文件。将其更新为 Example 3 ( InkStoryPlayerStatisticsScript.cs)

    更新后的代码首先设置 Story API。它是通过基于 Story 类创建一个新对象来做到这一点的。接下来,创建一个 List<string>。这个列表用于基于它们的字符串值作为变量名称的列表。在创建列表后,根据 mental_healthphyscial_health ink 变量的名称顺序向其中添加两个值。然后,将创建的列表传递给 ObserveVariables() 方法,并使用第二个参数,即 lambda 表达式形式的委托函数。

    Start() 方法以调用创建的 ProgressStory() 方法结束。在这个创建的方法内部,通过使用 ContinueMaximally()ChooseChoiceIndex() 方法程序化地推进故事。第一个方法加载所有文本内容直到第一个编织点,而第二个方法选择编织中的第一个(0)选项。最后,ContinueMaximally() 方法的第二次使用在 Ink 代码中加载结果文本,并且这是为了使变量发生变化。

  6. 将编译后的 Ink JSON 文件与 InkStory 游戏对象关联。

  7. 运行项目。

    当项目启动时,它将程序化地推进本节中使用的 ink 故事。因此,它将在传递给 ObserveVariables() 方法的 List<string> 中生成一条消息。然而,由于 ink 故事的推进,只有一个变量发生了变化。因此,变量发生了变化,其新值被传递回 Unity。当变化发生时,委托函数被调用,带有第二个参数,即变量的名称(variableName)和其新值(newValue)。

    ObserveVariables() 方法与它的姐妹方法 ObserveVariable() 方法工作方式类似。两者在 Ink 中发生时立即响应,返回变量的名称和更改后的值。它们之间的主要区别在于第一个参数。ObserveVariables() 方法接受 Ink 中单个变量的名称,而 ObserveVariables() 方法是一个变量列表,用于观察并使用委托函数响应。

  8. 停止项目。

本章本节重点介绍了ObserveVariables()方法的使用,与上一节中我们使用的ObserveVariable()姐妹方法模式相呼应。一般来说,两种方法都提供了一种控制 Unity 如何响应 ink 的方式,在两个系统之间转换信息控制。与variablesState属性一起,本章中涵盖的不同方法,如在第第八章“Story API – Accessing ink Variables and Functions”中所述,提供了对 ink 中变量的访问。根据开发者的需求,它们可以在项目中使用,要么更多地从 ink 端驱动 Unity 项目,要么根据需要直接从 Unity 代码在 ink 端更改值。

摘要

在本章中,我们探讨了多个示例。首先,我们从ObserveVariable()方法开始,只观察一个变量。在第二部分,我们动态地响应 Unity 中的 ink 故事。使用委托函数,我们学习了当 ink 变量发生变化时,代码的一部分才会被调用。在第三部分,我们探讨了使用ObserveVariables()方法来观察按名称指定的多个变量。

第十章,“使用墨迹的对话系统”中,我们将从 Story API 的个体属性和方法转向开始,将功能组合成更复杂的用例。结合在第第七章“Unity API – Making Choices and Story Progression”中引入的 Unity API 的部分,以及本章中涵盖的ObserveVariable()方法,我们将探讨如何创建不同的对话系统。

问题

  1. “观察”的动作是什么,它如何应用于Story类提供的方法?

  2. 在使用ObserveVariable()ObserveVariables()方法时,委托函数扮演什么角色?

  3. ObserveVariable()方法和ObserveVariables()方法之间有什么区别?

  4. 使用variablesState属性访问 ink 变量和使用ObserveVariable()方法或ObserveVariables()方法之间有什么区别?

第三部分:使用 ink 进行叙事脚本编写

在完成本章内容后,你将拥有使用 ink 及其 Unity 中的 Story API 编写的对话、寻路和简单程序化叙事系统的代码示例。本节包含以下章节:

  • 第十章, 使用 ink 的对话系统

  • 第十一章, 寻路追踪和分支叙事

  • 第十二章, 使用 ink 的程序化叙事

第十章:第十章: 使用 ink 的对话系统

在本章中,我们将探讨三种不同的方法来创建与故事中特定角色关联的 ink 中的不同行(#),然后我们将讨论标签的替代方案,即说话者的名字位于其对话之前。最后,我们将通过回顾标签的使用方法和两种方法的结合来结束第一部分。

在第二个主题中,我们将探讨如何使用 ink 重现许多视频游戏中常见的点击继续对话模式。我们将通过使用隧道在不同节点之间移动并在 ink 项目中保存时间与精力来探索各种节省时间与精力的方法。在此之后,我们将检查 ink 中生成对话树的不同方法,玩家可以通过扩展的对话分支探索不同的路径。

在第三个也是最后一个主题中,我们将探讨两种常见的视觉模式,用于向玩家展示对话选项,即列表和环形菜单,以及它们如何影响 ink 代码的编写和 Unity 中向玩家显示信息的方式。我们将从列表的视觉模式开始,其中所有选项都按垂直模式向玩家展示。然后,我们将检查环形菜单模式,其中选项限制在更少的选项中,这些选项以特定的、视觉的方式排列。

在本章中,我们将涵盖以下主题:

  • 使用标签在 ink 中编写对话

  • 对话循环和故事节点

  • 对话的用户界面模型

    注意

    与前几章不同,前几章的章节是朝着完成的项目构建的,而本章将探讨更多视觉复杂系统的不同方法。每个章节中涵盖的方法都可以在 GitHub 上作为完成的项目找到。本章中仅展示与每个章节方法相关的选定文件和代码。每个示例的具体文件也已在每个章节中注明。

技术要求

本章不同部分的完成代码可以在 GitHub 上找到,网址为github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language/tree/main/Chapter10

使用标签在 ink 中编写对话

当 ink 首次在第一章中介绍时,文本、流程、选择和编织,也讨论了单行的重要性。ink 中的每一行可以由代码、文本或两者的组合组成。根据其他概念的使用,如粘合剂或注释,单行可以由多个文本块组成,或者包括作为单行一部分的作者注释。然而,除了之前审查的概念之外,还有一个之前未讨论的概念:标签

在墨水(ink)中,当在任意文本前使用哈希符号(#)时,会创建一个新的单个 标签。从哈希符号(#)开始,直到该行的末尾,出现在这两个符号之间的任何文本都被视为单个 标签 的部分:

This is text. #This is a tag.

墨水中的标签专门设计用来与其他系统协同工作。在 Inky 本身中它们没有意义,并且在输出中间显示:

图 10.1 – Inky 中使用的哈希标签

图 10.1 – Inky 中使用的哈希标签

当与其他系统(如 Unity)一起工作时,标签可以用来在墨水单行中添加额外数据。加载的墨水故事块当前标签存在于 Story API 提供的属性中,称为 currentTags,它包含一个 List<string>,其中包含在最后一次加载故事内容中检测到的所有标签。与其他文本相关内容一样,currentTags 属性也会受到 Continue()ContinueMaximally() 方法的任何使用的影响。

我们将首先学习如何在墨水中使用标签。我们将使用 Unity 中的 currentTags 属性来检索它们的值,以构建一个简单的对话系统,其中每条说话的行都与一个与之通信的人的名字相关联。接下来,我们将检查使用文本前的语音标签来处理相同对话系统的不同方法。本主题的最后一部分将比较这两种方法,并回顾何时一个方法可能比另一个方法更好,或者是否需要两种方法的组合。

标记墨水文本

在墨水中,标签是按行使用的。它们仅存在于该行,但在使用 Continue()ContinueMaximally() 方法加载故事的下一部分之前,仍然是当前标签的一部分。在本节中,我们将回顾一个使用标签和说话者名字作为墨水单行一部分的示例。我们将学习如何使用 Continue()ContinueMaximally() 方法来影响 currentTags 属性。

提醒

本节完成的项目的示例可以在 GitHub 上的 第十章 找到;这些示例以 Chapter10-TaggingInkText 的名称命名。只会展示与本章探讨的概念相关的代码部分。

对话行如果没有指明是某个角色所说的话,则没有意义。这有助于确定谁在进行交流,并使你能够在故事中建立连贯性。在 Chapter10-TaggingInkText 示例中,每条对话的末尾都以其说话者的名字作为墨水中的标签结束。这有助于确定每条对话的说话者:

示例 1(InkDialogueTags.ink):

Hi, there! Welcome to an Ink example! #???
* [\[Continue\]]
- 
My name is Narrator! I will be guiding you through this example.
.. #Narrator
* [\[Continue\]]
- 
My name is Dan. #Dan
* [\[Continue\]]
-
I'm another character in this example! #Dan

示例 1 的墨水代码包含了每条对话的行和说话者的名字。在 Unity 中,这转化为使用 currentTags 属性在至少使用一次 Continue()ContinueMaximally() 方法后访问标签:

示例 2(InkStoryScript.cs):

void UpdatePanel()
{
DestroyChildren(OptionsPanel.transform);
InkOutputText.text = InkStory.ContinueMaximally();
SpeakerNameText.text = InkStory.currentTags[0];
}

示例 2中,因为currentTags属性是一个List<string>,所以可以使用其索引的数量来检索第一个(0)位置。结果是,尽管在墨迹中它们被写为同一行,但在 Unity 中仍然可以分离标记的说话者和他们的台词。

哈希标签是一种强大的工具,可以在墨迹中为单行添加额外数据。正如本节所示,它们可以用来在每次结束时添加传达该行的角色的名字。然而,还有另一种实现相同结果的方法。在下一节中,我们将重复相同的通用代码,但将在每行前面使用语标

使用语标

在创意写作中,一个语标出现在某些对话之前或之后,并表明谁在进行交流。例如,在许多小说中常见的例子可能会用以下方式使用单词

"Hello," Dan said.

“Dan 说”作为引用中话语的标签。它表明谁在说话(Dan)以及说了什么(Hello)。

通常,许多为游戏或其他交互式项目写作的人遵循一种略微不同的格式,其中说话者的名字出现在台词之前。这种风格借鉴了剧本中的惯例。在先前的示例中使用的相同词语可能如下所示:

Dan: Hello

在更新形式中,省略了引号的使用,说话者的名字出现在他们的话语之前。还有一个冒号(:)的引入。这标志着说话者的结束和话语的开始。在剧本中,说话者的名字和他们的对话都将居中。然而,在更常见地作为游戏写作一部分的更新形式中,这种格式被省略,文本作为一行的一部分出现。

提醒

本节完成的项目的示例可以在 GitHub 上的第十章示例中找到,名称为Chapter10-UsingSpeechTags。将仅展示与本章探讨的概念相关的代码部分。

Chapter10-UsingSpeechTags章节中的 ink 代码与上一节中找到的模式不同。它不再将说话者的名字作为哈希标签包含在对话行之后,而是现在在它之前。通常,这种格式被视频游戏和其他交互式项目的作家用于对话:

示例 3(InkSpeechTags.ink):

???: Hi, there! Welcome to an Ink example!
* [\[Continue\]]
- 
Narrator: My name is Narrator! I will be guiding you through this example.
* [\[Continue\]]
- 
Dan: My name is Dan.
* [\[Continue\]]
-
Dan: I'm another character in this example!

当在 Inky 中运行示例 3代码时,因为代码不再使用哈希标签,所以第一个输出和编织将被更新:

图 10.2 – Inky 中的语标使用

图 10.2 – Inky 中的语标使用

立即,使用墨迹井号和用语音标签格式化对话之间存在视觉差异。在 Inky 中测试代码时,很明显谁在交流,因为他们的名字将出现在文本之前。然而,尽管在 Inky 中测试更容易,但墨迹代码中井号的移除意味着无法使用currentTags属性。相反,必须添加更多的 C#代码来从每行文本中解析名字。

要检测、解析和删除墨迹输出中的冒号(:)的使用,需要多行 C#代码:

示例 4(InkStoryScript.cs):

void UpdatePanel()
{
DestroyChildren(OptionsPanel.transform);

string inkOutput = InkStory.ContinueMaximally();
if(inkOutput.Contains(":"))
{
string[] splitInkOutput = inkOutput.Split(':');
splitInkOutput[0] = splitInkOutput[0].TrimEnd(':');
SpeakerNameText.text = splitInkOutput[0];
InkOutputText.text = splitInkOutput[1];
}
else
{
SpeakerNameText.text = "";
InkOutputText.text = inkOutput;
}
}

示例 4现在使用Contains()方法检测输出中是否存在冒号(:)。如果存在,则使用Split()方法将字符串分成两部分。然后使用Trim()方法从第一个(0)字符串中去除冒号(:),其值随后用于演讲者的名字。第二个(1)字符串用于墨迹的输出。

这段新代码的结果看起来与上一节相同。然而,它使用语音标签来标记谁在说话以及何时说话。这使得在 Unity 之外测试墨迹代码变得更加容易,因为在 Inky 中墨迹的井号没有意义。然而,这种方法也带来了一个问题,即冒号(:)只能作为语音标签的一部分出现。如果文本中包含冒号,C#代码可能会困惑并尝试将文本分割成似乎包含语音标签的样子。

在下一节中,我们将比较之前概述的每种方法:

  • 第一种方法,使用墨迹中的井号,允许我们在单行中添加额外数据,然后使用currentTags属性在 C#代码中检索这些数据。

  • 第二种方法,直接在文本中使用语音标签,使得墨迹代码更容易测试,但需要更多的 C#代码来解析生成的墨迹。

正如我们将在下一节中提到的,可能存在两种方法都可以结合使用的情况。

检查标记对话的方法

在墨迹中,每个井号都会为每行添加额外数据。正如我们在标记墨迹文本部分所学,它们可以用来给每行对话添加井号,然后使用 C#代码中的currentTags属性作为 Unity 项目的一部分来检索这些数据。然而,墨迹中的井号有两个问题。第一个是它们只能按行使用。第二个是每次只能使用一个标签。这使得井号非常有用,例如,在添加谁在交流到行中的任务中,但也意味着它们只能每行使用一次。

对话可以直接在文本中使用语音标签进行标记。正如我们在使用语音标签部分所学,冒号可以用来标记说话者是谁以及他们正在传达什么。这对于在 Inky 中进行测试非常有用,因为说话者和他们的台词紧密相连,并一起出现。然而,在墨水中使用语音标签需要额外的 C#代码来理解输出。此外,这意味着冒号只能作为语音标签的一部分使用,因为任何其他用户都可能会造成混淆。

当单独使用时,这两种方法都有其优点和潜在障碍。然而,在某些情况下,这两种方法可以结合使用,以语音标签的形式呈现说话者姓名,并使用墨水中的标签同时传达额外数据。例如,许多游戏不仅向玩家展示文本,还使用音频、视频或与文本本身紧密相关的某种类型的动画。在这些情况下,文本可以包含语音标签,墨水代码也可以使用标签来指示应作为对话向玩家传达的合并交付的一部分播放的额外媒体。

对于有语音对话行的游戏,使用数据库或电子表格来存储文本行及其对应的音频,并根据命名约定作为同一行的一部分是非常常见的。根据团队、公司和其他因素,命名约定可能使用特定的格式或数字,但一个一般示例可能包括音频类型、角色姓名、他们的心理状态或情绪以及与游戏上下文、级别或区域相关的任何其他信息:

dialogue_diana_happy_desert.mp3

由于墨水标签可以添加额外数据,墨水代码中的一行可以使用语音标签来标记谁在交流,然后在该行的媒体文件标签后使用标签来播放。此类代码将结合两种方法。

提醒

本节完成的工程可以在 GitHub 上的第十章示例中找到,名称为Chapter10-CombiningTags。由于与本章探讨的概念相关,只会展示代码的部分内容。

Chapter10-CombiningTags示例中的墨水代码使用的是结合方法。它包括对话行前的说话者姓名以及当行显示时使用的对应媒体文件或引用的标签:

示例 5 (InkCombiningTags.ink):

Diana: I love the desert! #dialogue_diana_happy_desert
* [\[Continue\]]
- 
Diana: But I hate how hot it gets! #dialogue_diana_sad_desert
* [\[Continue\]]
- 
Diana: Perhaps I'm just fickle. #dialogue_diana_shrug_desert
* [\[Continue\]]
-

为了简化,本节的工程只显示标签的文本。通过添加一个额外的Text游戏对象并将其与现有属性关联,调整后的 C#代码将包含对解析语音标签的使用以及currentTags属性的用法:

示例 6 (InkStoryScript.cs):

if(InkStory.currentTags.Count > 0)
{
MediaText.text = InkStory.currentTags[0];
}
else
{
MediaText.text = "";
}

示例 6 中,新的代码测试 currentTags 属性中的条目数量。如果它至少包含一个标签,则第一个(0)条目用作 Text 游戏对象的文本。运行项目时,项目将显示说话者、他们的交流以及将要播放或作为对话一部分使用的媒体文件的名称,字体较小。

在下一个主题中,我们将重新创建一些在视频游戏对话中常见的模式。我们将学习如何创建点击继续模式以及更复杂的对话树供玩家探索。同时,也会为那些开始新项目的人提供如何规划和允许 ink 中的代码引导你创建 Unity 界面的建议。

对话循环和故事节点

在 ink 中编写对话通常意味着要意识到它将如何与其他系统一起使用。在前一节中,我们研究了在编写单行对话时使用标签的两种方法。在本主题中,我们将从关注单个行转向处理 ink 项目中的更大结构。通过检查两种向玩家呈现选择时的常见模式,我们将学习 ink 中的节点如何在项目中重用以节省未来的时间和精力。本主题的最后一部分还包括有关开始新项目或使用 ink 转换项目的建议。

因为它出现得最频繁,所以我们将从一个出现在 ink 代码示例中的使用标签编写 ink 中的对话部分的模式开始,该模式是作为点击继续的一部分:点击继续。

点击继续

提醒

本节完成的项目的示例可以在 GitHub 上的第十章示例中找到,名称为 Chapter10-ClickToContinue。将仅展示与本章探讨的概念相关的代码部分。

在视频游戏写作中可以发现许多重复的模式。其中最常见的是点击继续模式。这是将信息通过一系列消息呈现给玩家,他们必须按下按钮或点击屏幕才能继续的过程。

在 ink 中,创建点击继续模式的一种方法是通过一个单一的选择,然后通常是一个聚集点,在它之后立即折叠编织。在其最简单的形式中,它只包含这些概念和一个表示动作的单个单词,例如 继续

* [\[Continue\]]
-

将点击继续的代码拆分成一个节点允许作者通过一次编写并在需要时通过隧道进入和返回来多次重用相同的部分。在一个扩展的例子中,用于此目的的特定节点出现的次数越多,节省的代码行数就越多:

示例 7 (InkClickToContinue.ink):

Guard: Sir! A dragon! There's a dragon!
-> continue ->
King: What? Are you sure?
-> continue ->
Guard: Let me check!
-> continue ->
Guard: Just a large bird, turns out.
-> continue ->
Guard: Sorry, sir.
-> continue ->
== continue ==
+ [\[Continue\]]
-
->->

示例 7 的代码中,continue ink 节点被多次重用。每次使用都会进入节点并返回。这使得代码可以减少总行数。

根据墨水的结构,C#代码的数量也可以减少。墨水代码中的模式可以通过提供一个方法和将其附加到包含两个Text游戏对象的整个面板上来反映在 C#代码中的简化技术:

示例 8(InkStoryScript.cs):

void UpdatePanel()
{
SpeakerNameText.text = "";
InkOutputText.text = "";
string inkOutput = "";
if (InkStory.canContinue)
{
inkOutput = InkStory.ContinueMaximally();
}
if (inkOutput.Contains(":"))
{
string[] splitInkOutput = inkOutput.Split(':');
splitInkOutput[0] = splitInkOutput[0].TrimEnd(':');
SpeakerNameText.text = splitInkOutput[0];
InkOutputText.text = splitInkOutput[1];
}
else
{
SpeakerNameText.text = "";
InkOutputText.text = inkOutput;
}
}
public void ProgressDialogue()
{
if(InkStory.currentChoices.Count > 0)
{
InkStory.ChooseChoiceIndex(0);
}
UpdatePanel();
}

在新的示例 8代码中,ProgressDialogue()方法被一个Panel游戏对象作为事件触发(EventTrigger)组件的一部分使用:

图 10.3 – 面板游戏对象的触发事件

图 10.3 – 面板游戏对象的触发事件

通过将ProgressDialogue()方法与Panel游戏对象关联,你可以点击对话的视觉表示。然后根据墨水代码加载点击继续模式的下一部分。

虽然点击继续模式是最常见的,但在许多角色扮演游戏和叙事密集型交互项目中,还存在另一种更高级的模式:对话树。在这个模式中,会展示多个选项,每个选项都扩展成独立的对话分支供玩家探索。在下一节中,我们将学习如何在墨水中创建这种模式以及如何轻松地将新选项添加到分支中。

对话树的选择计数

在墨水(ink)中,一个编织(weave)由一个或多个选择(choices)组成。根据每个选择后的代码,可以创建多个层级,墨水故事的流程可以分支成不同的路径。当涉及到展示选项时,通常会有这样的上下文:用户会通过所谓的对话树(dialogue tree)来推进。使用“树”这个词是为了描述由不同部分或分支形成的形状,所有这些分支共同构成一个单一的树干。

在角色扮演和叙事密集型游戏中,这种模式通常作为场景的一部分出现,其中包含有关事件的信息,或者作为角色向玩家解释某事的一部分。在这些场景中,正常使用编织并不完全符合预期。我们不需要在集合中选择一个单一的选择,而是需要跨过集合前进。为此,需要一个特殊的内置墨水函数:CHOICE_COUNT()

墨水运行时跟踪加载块中当前选项的数量。这个数字可以作为CHOICE_COUNT()墨水函数的一部分访问。当作为墨水中的条件选项的一部分使用时,这允许作者通过将当前计数与CHOICE_COUNT()函数返回的值进行比较来限制向读者展示的选项数量。然而,为了在循环之间跟踪值,需要一个变量:

示例 9(oneBranch.ink):

VAR count = 0
-> loop
== loop
~ count = CHOICE_COUNT()
* {limitChoice(count)} This is the first
* {limitChoice(count)} This is the second
* {limitChoice(count)} This is the third
+ Return
- -> loop
== function limitChoice(localCount) ==
~ return localCount == CHOICE_COUNT()

示例 9 中使用 count 变量记录循环开始时的当前选择计数。然后,对于每个选择依次,将值与比较之前选择使用增加的数量进行比较。结果是依次从集合中加载每个选择。在循环开始时,将提供“这是第一个”选项。使用集合点将自动循环代码。这将一直持续到除了“返回”的粘性选择之外没有其他选项。最后一个选择将始终保留,并允许玩家关闭对话或 返回 到先前的位置。

此模型也可以扩展为多个分支。对于每一棵树,都需要一个单独的结或缝合点,使用隧道在它们之间移动以保持墨水故事的流动。使用具有其选择计数的多个部分还意味着使用另一个墨水概念:临时变量。temp 关键字可以在任何结或缝合点内部使用来创建一个在它之外不存在的变量:

示例 10 (multipleBranches.ink):

-> loop
== loop
<- tree1.branch1
<- tree1.branch2
+ \[Close\]
    -> DONE
- -> loop
== tree1
= branch1
~ temp count = CHOICE_COUNT()
* {limitChoice(count)} Branch 1, first
* {limitChoice(count)} Branch 1, second
* {limitChoice(count)} Branch 1, third
- -> loop
= branch2
~ temp count = CHOICE_COUNT()
* {limitChoice(count)} Branch 2, first
* {limitChoice(count)} Branch 2, second
* {limitChoice(count)} Branch 2, third
- -> loop
== function limitChoice(localCount) ==
~ return localCount == CHOICE_COUNT()

示例 10 的前述代码中,每个分支都被拆分到更大的集合结内的单独缝合点中。从 loop 结开始,使用线将两个缝合点拉入并创建来自代码两个不同部分的选项的统一外观。

根据项目的结构,可以使用 CHOICE_COUNT() 墨水函数来限制每个集合中按顺序的一个选择,或者创建一个更传统的集合。这些方法中的每一个都提供了为玩家创建对话树的不同方式。他们可以依次耗尽每个选项,或者使用隧道将流程传递到包含树结构的结,然后再返回。

在本节中,我们考察了两种不同的对话系统结构:点击继续和对话树。在本章的最后一个主题中,我们将最终从墨水结构过渡到它们在 Unity 中的视觉表示。我们将考察两种向用户展示选项的模型:列表和径向菜单。我们将确定何时使用每个模型最佳,以及这些模型如何影响墨水中的结构和 Unity 中的设计。

对话的用户界面模型

在电子游戏和其他交互式项目中向玩家展示对话选项有着悠久的历史。从最早期的文本提示到更现代电子游戏中复杂的菜单层,每一代电子游戏系统都引入了不同的信息展示方法。然而,在许多游戏中出现了两种通用模型:列表和径向菜单。它们可以这样解释:

  • 基于一个接一个的垂直排列中的原始选择展示,列表模式首先出现在早期的计算机游戏中,并在有更多空间展示各种较长的文本选项给玩家的视觉设计中继续存在。

  • 第二种模型,环形菜单模式,通常出现在视频游戏机或移动游戏空间中的角色扮演游戏中,在这些游戏中,视觉空间有限,因此选项以圆形排列,以便在控制器使用时易于访问。

在第一部分,我们将从列表开始。正如我们在本书中的多个 Unity 示例中提到的,以及在本章早期主题中提到的,选项的垂直排列是一种非常常见的方法。然而,在我们转向介绍环形菜单模型之前,我们将讨论使用此模型时的一些常见陷阱,并回顾一些最佳使用和可能需要避免使用的示例。

列出对话选项

当考虑列表的用户界面模型时,我们应该问一个问题:每个选项允许多少视觉间距?在以文本为重点或叙事设计较重的计算机游戏中,列表模型通常是最佳选择。然而,这并非基于计算机游戏本身,而是基于假设的系统输入外围设备。通常,计算机游戏使用鼠标作为主要输入。这意味着用户可以点击各种东西,并滚动通过一个长长的列表选项。因为用户习惯了这种输入方式,并且愿意通过较长的文本展示进行移动,所以列表通常是一个很好的模型。

提醒

本节完成的项目的示例可以在 GitHub 上的第十章中找到,名称为Chapter10-ListingOptions。将仅展示与本章探讨的概念相关的代码部分。

在一些角色扮演或视觉小说为基础的视频游戏中,玩家可能会根据他们与其他角色、政党或组织的过去关联,面对许多选项。选项的数量也可能受到某些技能、特质或其他游戏中给予玩家额外对话选择利益的特权的影响。Chapter10-ListingOptions项目就是基于这样的前提。

在墨迹代码中展示的对话中,玩家在一艘客船上,正在前往另一个城市,在那里他们在船员专用区域遇到了另一个角色。玩家有多种程序化的选项可以考虑,如下所示:

示例 11(InkListingOptions.ink):

You sneak into the crew-only area. After you close the door, a man quickly stands up from what he was doing on the floor. Behind him seems to be a corpse on the floor.
* "Just give me any money you have, and I won't tell the captain you have been murdering on his ship."
* "Is that dark magic!? I'll go to report you to the captain right now!"
* "I don't care what you are doing in here. Leave. Now."
* \[Necromancer\] "Praise the Bone Mother! What foul sorcery have you been up to? And can I help?"
* "Oh, gosh. I totally forgot to clean up that body earlier. I guess I must kill you now too."
* \[Ignore them.\]

由于代码中示例 11的对话选择占用的视觉空间量,多个选项延伸到了屏幕之外。玩家必须向下滚动并仔细阅读列表,以便考虑他们的选择。这种界面在注重叙事的视觉设计中工作得很好,或者在桌面计算机等平台上,用户可能在通过对话树进行决策之前感到舒适地回顾一切。然而,这并不是开发者想要使用的唯一模型。

在下一节中,我们将探讨径向菜单模式。这种模式在视频游戏控制台上的角色扮演游戏中流行起来,因为输入的数量有限。径向菜单模式不仅提出了设计挑战,也提出了写作挑战。正如我们将更详细地探讨的那样,径向菜单模式限制了屏幕上显示的文本量,迫使开发者确保单个单词或短语的意图传达了玩家选择选项时将体验到的结果。

展示对话的径向菜单

许多视频游戏控制器至少有一个摇杆和有限数量的按钮。由于这个输入集的减少,为玩家设计用户界面以在多个选项之间进行选择通常意味着在屏幕上以顺时针模式展示选项。更常见的是,这种视觉设计模式被称为径向菜单。这个术语的名字来源于数学概念半径,即从圆心到圆周的距离。径向菜单基于圆形模式展示选项。

提醒

本节完成的项目可以在 GitHub 上的第十章示例中找到,名称为Chapter10-OptionWheel。由于与本章探讨的概念相关,只会展示代码的部分内容。

通过展示径向菜单的常见用法,Chapter10-OptionWheel示例呈现了一个场景,玩家必须面对一扇门,并基于他们在游戏中的统计数据拥有多种技能。每个选项的结果由统计数据的名称表示:

示例 12(InkOptionWheel.ink):

* [Strength]
    You kick the door down.
* [Intelligence]
    With a careful touch to two places where the wood has       rotted, the door falls flat.
* [Wisdom]
    You reach over and turn the knob. The door opens.
* [Charisma]
    You turn to your companions and nod towards the door. One       of them opens it for you.

示例 12的代码中,有四个选项,每个选项都有一个示例游戏统计数据的名称。当以简化的径向模式排列时,它们在 Unity 中可能看起来如下:

图 10.4 – 对话选项的简化径向菜单排列

图 10.4 – 对话选项的简化径向菜单排列

径向菜单模式自带减少视觉空间的内置限制。正如在列出对话选项部分中提到的,分配给选项展示的视觉空间决定了信息如何呈现。对于径向菜单模型,这一点尤为重要。

示例 12的代码一样,屏幕上显示的选项必须与游戏中的统计数据或玩家已知的结局相匹配。例如,玩家可能知道如果他们选择某个图标,它将匹配某个动作。在这些情况下,他们将在 Ink 中无法用文字表示选项,Unity 将承担更多责任,将选项作为用户界面的一部分呈现给玩家。

在本节中,我们回顾了环形菜单模式。以顺时针排列呈现选项,这种模式通常作为具有控制器或有限视觉空间的视觉设计的一部分出现在视频游戏控制台对话系统中。然而,该模式的使用直接影响到 ink 中选项的编写。在一个模式中,即列表,可以包含较长的句子,但玩家可能一次看不到所有这些。对于另一种模式,即环形菜单模式,选项仅是单个单词或甚至代表更复杂反应的图标。

摘要

在本章中,我们探讨了三种不同的对话系统方法。在第一种方法中,我们通过标签和语音标签进行了操作。在 ink 中,我们可以在一行的末尾添加一个标签。这允许你为每行添加额外信息,例如对话的说话者或为该行播放的媒体文件。使用语音标签,可以在对话前添加一个冒号(:)来标记说话者。语音标签有助于使用 Inky 进行测试,但需要在 Unity 中编写更多的 C#代码。在可以结合各种上下文的情况下,标签和语音标签可以组合使用,其中标签可以代表媒体文件或开发者的额外数据,而语音标签包含谁在传达该行。

在第二种方法中,我们从带有标签的逐行强调扩展到 ink 中的结构。为了复制点击继续模式,我们可以结合 ink 中的节点和隧道的使用。这也适用于对话树,我们可以将其从更大的节点中分离出来作为自己的线。我们还学习了CHOICE_COUNT() ink 函数的使用以及如何遍历一系列选项。

在最终的方法中,我们从墨迹扩展到考虑 Unity 中的视觉设计以及它们如何影响对话的编写。所使用的模式,无论是列表还是环形菜单,都将决定在 ink 中如何创建对话。对于列表,其中每个选项可以包含多行文本,玩家一次只能看到有限的选择。对于环形菜单模式,其中选项在屏幕上以顺时针模式呈现,ink 中的对话将受到限制或不存在。在任一情况下,用户界面元素的视觉空间直接影响到选项如何呈现给玩家。

第十一章 任务追踪和分支叙事中,我们将从较小的对话系统转向更大的任务追踪和创建分支叙事。虽然许多视频游戏经常向玩家呈现对话,但一些游戏会在较长时间内跟踪多个值。我们将研究如何使用LIST ink 关键字来跟踪任务进度,以及如何将较大的 ink 项目拆分为多个文件以简化资产维护。

问题

  1. ink 中的标签是什么?

  2. 标签和语音标签之间的区别是什么?

  3. 对话树”这个术语是如何得名的?

  4. 什么是列表模式

  5. 什么是径向菜单模式

第十一章:第十一章:任务跟踪和分支叙事

在本章中,我们将回顾如何创建任务的 ink 模板,根据此模板跟踪多个任务,并显示玩家跨任务变量的值。在第一部分,我们将创建 ink 模板及其所需的部分。接下来,我们将改进 ink 模板并创建一个Quest类来跟踪多个独立进展的任务。最后,我们将向玩家展示任务进展的结果并查看在此进展过程中的变化值。

许多大型或以叙事为重点的视频游戏由玩家需要完成的独立任务组成。本章将提供一个在 ink 中创建任务的模板,并展示如何在使用 Unity 中访问和操作此模板。通过使用多个任务,允许玩家分别通过每个任务进行进展,正如本章所解释的那样。

在本章中,我们将涵盖以下主题:

  • 让任务有意义

  • 跨多个任务跟踪进展

  • 显示和奖励玩家进展

    重要

    本章的每个主题都有一个独立的、完成的 Unity 项目。每个主题都包含说明,说明项目的名称以及如何找到它。

技术要求

本章的示例已按项目划分文件夹,可在 GitHub 上找到:github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language/tree/main/Chapter11

让任务有意义

从叙事的角度来看,任务是一系列与故事中某个角色相关的事件。在视频游戏中,任务是由玩家体验的一系列相关事件。在角色扮演游戏中,一个任务可能包括解锁武器、营救王子或击败某个大恶势力。沿途的每个点都是任务的步骤。从故事和代码的角度来看,任务可以被视为一系列步骤,其中每个步骤的解决都会解锁下一个步骤。

ink 支持在更大的整体中包含较小部分的模式,就像绳结中的线头。这样想,任务的每个步骤都可以成为代码中的自己的线头,每个线头的结局能够移动到更大的结构中的下一个位置。在 ink 中使用LIST也允许我们通过名称定义我们想要的步骤,有一个特殊的结将玩家从任务结构中的一个线头推进到下一个线头。

在本节中,我们将学习如何在 ink 中设计任务模板并访问其在 Unity 中单个项目中的值,每个部分都是在前一个部分的基础上构建的:

  • 在第一部分,在 ink 中创建任务模板,我们将回顾如何使用此模式以及通过使用现有的 Ink 函数可用的内置自动化。

  • 在第二部分,在 Unity 中选择特定的节点,我们将从 Ink 转向 Unity。在这里,我们将检查如何在 Ink 中运行模板,以及在使用某些 Story API 方法时需要注意的一些潜在问题。

    提醒

    本节的完成项目可以在第十一章中找到,GitHub 文件夹下的示例,名称为Chapter11,QuestProgression。只会展示与该主题各部分探讨的概念相关的代码部分。

现在我们来了解下一个主题,我们将创建一个任务模板。

在 ink 中创建一个任务模板

ink 故事由不同的部分组成。在第一章中,文本流、选择和编织,我们学习了 ink 如何将代码分解为不同的部分,称为 ink 中的VARLIST关键字,我们在第四章中,变量、列表和函数,我们可以创建一个由一系列步骤组成的任务:

LIST stages = (one), (two), (three)
VAR stage = one
VAR end = false

这段代码在 Ink 中创建了一个名为steps的列表,一个名为step的变量和一个名为end的变量。这三个值跟踪任务的进度。任务中每个步骤的名称都被添加到列表中,第一个被用作stage变量的值:

You meet an old man by the side of a dusty road with a wide hat set out in front of him. "Got any change?"
* [Sure]
    -> quest
* [Not today]
    -> quest.stop

玩家将看到他们的第一个选项:“当然”和“今天不”。如果选择第一个,流程将进入quest节点。如果选择第二个,流程将移动到quest节点内的一个名为stop的针脚:

== quest
{step:
    - one: -> first
    - two: -> second
    - three: -> third
}
-> DONE

选择queststop这两个名称是经过特别考虑的。使用quest这个词有助于理解代码作为其他任务模板的一部分。正如下一节将要概述的,跨多个任务跟踪进度,在 Unity 中可以同时存在多个Story对象。在这种情况下,这个词被用来展示模式。

quest节点包含这个模式的中心逻辑。因为 ink 中LIST的条目是布尔值(要么是true要么是false),所以使用多行比较,顺序很重要。名为steps的列表包含三个条目,每个条目最初都设置为true。当第一次遇到quest节点时,它将流程移动到名为first的针脚:

= first
You empty some coins from your pocket and the old man nods. "Thanks, stranger! May the gods bless you!"
-> DONE

第一个针脚包含一个不寻常的结尾。在 Ink 中,使用DONE关键字通常表示故事以墨水结束。然而,在这种情况下,DONE关键字被用来表示步骤已完成。故事并没有转向quest节点或另一个部分,而是看似停止了。进度是通过结合LIST_MIN()函数和 Ink 中列表的减法(-)操作来实现的:

== progress
~ steps -= LIST_MIN(steps)
~ step = LIST_MIN(steps)
-> quest

progress节点中,每个条目都会被移除(减去),而顶部(最小)值则作为step变量的一个部分。每次使用此节点都会通过从列表中移除一个步骤并使用剩余的最高值来推进任务。然而,节点本身并不是直接访问的。相反,它通过 Unity 外部使用。

在本节中,我们学习了如何将每个步骤作为名为quest的节点的一部分进行细分。通过使用 ink 中的LIST和不同的变量,可以在整个任务中跟踪进度。在下一节中,我们将学习如何将 ink 模板用作一系列步骤,以在 Unity 中外部访问progress节点。

在 Unity 中选择特定的节点

ink-Unity 集成插件提供的 Story API 的不同方法和属性在第七章,“Unity API – Making Choices and Story Progression”和第八章,“Story API – Accessing ink Variables and Functions”中都有涉及。然而,那些章节中没有涵盖的一个有用但可能非常危险的方法名为ChoosePathString()。在本节中,我们将探讨如何安全地使用此方法的一个示例。

在 ink 运行时内部,术语“路径字符串”用于描述故事中任何作为部分的节点。当加载时,可以通过使用ChoosePathString()方法来访问这些路径,该方法强制将故事移动到该部分。在大多数情况下,这种行为是不受欢迎的,因为它的使用将忽略任何现有的隧道或线程。它可以被看作是撕裂流动从它所在的位置并丢弃到新的位置。

作为 ink 运行时的一部分,变量是全局的。这意味着虽然它们的值可能会作为故事流程的一部分而改变,但它们存在于故事之外。变量的值在ChoosePathString()方法的使用过程中得到保持。换句话说,通过仔细避免任何可能被其使用所破坏的结构,可以在项目中谨慎地使用ChoosePathString()方法。

在上一节中,介绍了 ink 的progress节点。要使用ChoosePathString()方法在 Unity 中选择此路径字符串,只需要它的名称:

public void Progress()
{
     InkStory.ChoosePathString("progress");
     FlipProgress();
    UpdateContent();
}

当使用progress ink 节点与ChoosePathString()方法时,它确实会中断保存在InkStory C#变量中的故事流程。然而,正如我们所学的,由于它们的全局性质,变量的值在整个故事中保持不变。使用progress ink 节点通过每次更新变量来推进任务到其下一个步骤。

代码还包括对两个其他方法的调用:FlipProgress()UpdateContent()。第一个方法使用 Unity 中的SetActive()方法将 Unity 中的Button游戏对象设置为非活动状态。当游戏对象在 Unity 中被关闭(设置为非活动状态)时,它不会出现在屏幕上。此代码有效地将游戏对象设置为按需显示,并在玩家选择对话选项时消失:

void FlipProgress() {
ProgressButton.gameObject.SetActive(!ProgressButton.gameObject.activeSelf);
}

第二个方法UpdateContent()遵循我们在第七章中首次介绍的模式,即Unity API – Making Choices and Story Progression,其中使用Prefab动态创建所需的Button游戏对象:

void UpdateContent()
{
     DestroyChildren(OptionsPanel.transform);
     DialogueText.text = InkStory.ContinueMaximally();
     foreach (Choice in InkStory.currentChoices)
     {
          Button choiceButton = Instantiate(ButtonPrefab,
             OptionsPanel.transform);
           choiceButton.onClick.AddListener(delegate
           {
                InkStory.ChooseChoiceIndex(choice.index);
                FlipProgress();
                UpdateContent();
           });
             Text choiceText =
             choiceButton.GetComponentInChildren<Text>();
           choiceText.text = choice.text;
      }
}

在本节中,我们学习了如何在 ink 中创建任务模板。使用节点和每个步骤的单独线迹,部分被分为不同的部分。接下来,我们看了progress ink 节点。之后,我们看了 Unity 和ChoosePathString()方法。虽然在使用更高级技术的较大项目中可能存在潜在危险,但在 ink 任务模板中使用变量有助于保持它们的值。最后,在本节的最后,Unity 中的ChoosePathString()方法与 ink 中的progress节点配对。通过使用此节点,Unity 代码可以推进任务,ink 更新其内部变量。

在下一节中,我们将继续在本节中查看的模式,通过扩展 ink 中的部分任务模板并创建 C#中的QuestDialogue类。这将允许我们同时跟踪多个任务。

跨多个任务跟踪进度

在上一节中,我们为任务创建了一个 ink 模板,然后进入 Unity 创建用户界面,使用ChoosePathString()方法推进任务。这迫使 ink 中的流程移动到特定位置。在本节中,我们超越了单个任务,并开始同时跟踪多个任务。为此,ink 模板需要额外的变量。为此,我们需要 C#中的QuestDialogue类。我们还将停止使用单个 ink 文件,并开始使用多个文件。对于每个任务,我们将创建一个单独的文件,并使用Quest类在 Unity 中跟踪每个任务的进度,Dialogue类则负责在任务的每个步骤中为玩家创建选择选项。

首先,我们将使用 Unity 中稍后要访问的新变量更新墨迹模板。然后,我们将在 Unity 中创建QuestDialogue类。之后,我们将访问 Unity 中的多个 ink 文件,以展示包含多个任务的界面。最后,我们将允许用户在任务之间切换,并独立推进每个任务。

提示

本节完成的项目的示例可以在 GitHub 的 第十一章 示例 文件夹中找到,名称为 Chapter11-MultipleQuests。只会展示与该主题章节中探讨的概念相关的代码部分。

基于 ink 任务模板构建

正如我们在 第八章 中看到的,Story API – Accessing ink Variables and Functions,我们可以使用 Unity 中的 variablesState 属性访问使用 VAR 关键字创建的 ink 变量。这允许我们根据变量的名称检索变量的值。考虑到这一点,现有的 ink 模板可以扩展以包括每个任务的新的变量 – 它的 name

通过理解编译后的墨迹文件将在 Unity 中操作,我们可以预测我们在任务中可能需要的某些需求。例如,一个任务通常有一个名称。然后我们可以在 Ink 中定义这个变量,并与其现有的值一起:

LIST steps = (one), (two), (three)
VAR step = one
VAR end = false
VAR name = "Old Man's Change"

一旦我们知道 ink 中的 name 变量存在,我们就可以在 Unity 中读取它。然而,与之前章节中展示的不同,我们还需要观察 end ink 变量:

InkStory = new Story(text);
Name = (string)InkStory.variablesState["name"];
End = (bool)InkStory.variablesState["end"];
InkStory.ObserveVariable("end", delegate
{
      End = (bool)InkStory.variablesState["end"];
});

这些小的变化可能看起来并不重要,但通过建立一个模式,其中某些变量存在(nameend),并且所有包含任务的文件也将有一个名为 progress 的节点(如前一小节中定义的),如果这些部分保持不变,我们就可以编写任何我们想要的任务。

一旦这些变量已经准备好,我们就可以开始创建 QuestDialogue 类,这些类将持有我们在 ink 中定义的值并在 Unity 中读取它们。

在 Unity 中创建 Quest 和 Dialogue 类

在前一小节中,我们将对话选项的简单展示与推进单个任务的值和方法结合起来。在本节中,我们将将该功能拆分为 Unity 中的两个新类:QuestDialogue

大部分的 Quest 类在前一小节中已经展示。然而,它的目的是持有 Story 对象,并公开一个名为 Progress() 的方法,该方法内部调用 ChoosePathString() 方法:

using Ink.Runtime;
public class Quest
{
    public Story InkStory;
    public string Name;
    public string Description;
    public bool End;
    public Quest(string text)
    {
        InkStory = new Story(text);
        Name = (string)InkStory.variablesState["name"];
        End = (bool)InkStory.variablesState["end"];
        InkStory.ObserveVariable("end", delegate
        {
            End = (bool)InkStory.variablesState["end"];
        });
    }
    public void Progress()
    {
        InkStory.ChoosePathString("progress");
    }
}

Quest 类很小,因为它被其他类使用。它的唯一目的是包含任务(ink 故事)并提供一种推进任务的方式(通过 Progress() 方法)。

新的类 Dialogue 执行创建所需的 Button 游戏对象的大部分工作,并记住最后一条对话内容,作为对玩家的提醒。它的 UpdateContent() 方法看起来像在 第七章 中首次展示的示例代码,Unity API – Making Choices and Story Progression,除了使用新的 Quest 类:

public void UpdateContent()
{
     DestroyChildren();

     if(quest.InkStory.canContinue)
     {
           DialogueText.text =
             quest.InkStory.ContinueMaximally();
           lastDialogue = DialogueText.text;
      }
      else
     {
            DialogueText.text = lastDialogue;
     }

在第一节的更新墨迹模板和本节中引入 QuestDialogue 类的基础上,还需要两个部分:

  • 任务需要基于编译后的 JSON 文件

  • 玩家需要能够切换他们正在进行的任务

在下一节中,我们将从学习如何读取文件和为每个找到的文件创建一个新的Quest类开始。

组织多个任务文件

在本书的前例中,每个项目使用了一个 ink 文件。本节将打破这种模式。为了跟踪多个任务,我们将为每个任务定义一个单独的文件,然后读取编译后的 JSON 文件。我们在上一节中查看的Quest类将包含内容并暴露每个文件中的某些值。Dialogue类将根据Quest类的值创建玩家将看到的选项。然而,首先我们需要读取文件。

本书遵循 Unity 文件夹的推荐命名约定,并将所有 Ink 文件放置在一个名为Ink的文件夹中。使用项目设置窗口中的自动编译所有 Ink选项,每个创建的 ink 文件也将包含一个 JSON 文件:

图 11.1 – Ink 文件夹中的编译 JSON 文件

图 11.1 – Ink 文件夹中的编译 JSON 文件

由于 Unity 可以在许多不同的操作系统上运行,它暴露了Assets文件夹(如Application.dataPath所示。这是数据的路径,作为当前运行应用程序的一部分。基于此值,可以找到任何额外的文件夹,并访问它们的文件:

void GetFiles()
{
     string inkPath = Application.dataPath + "/Ink/";
     foreach(string file in Directory.GetFiles(inkPath,
       "*.json"))
     {
           string contents = File.ReadAllText(file);
           quests.Add(new Quest(contents));
     }  
}

通过使用Application.dataPath属性,每个编译的 ink 文件(JSON 文件)都可以被读取,并基于Quest类创建一个新的对象。这不仅允许每个任务通过它们的任务进度独立运行,而且还暴露了Dialogue类可以使用以向玩家展示不同选项的值。

在下一节和最后一节中,我们将编写一些代码,允许玩家在哪个任务对他们来说是活动的之间切换,并看到QuestDialogue类的实际应用。

切换任务

在 Unity 中,Toggle游戏对象允许用户在多个项目中选择一个。对于本节中该项目的使用,必须创建一个Toggle预制件。就像Button游戏对象的使用一样,这些是按需创建的。由于每个Toggle游戏对象基于使用Quest类的对象,这意味着首先运行了上一节中显示的GetFiles()方法(组织多个任务文件),然后使用生成的任务来创建Toggle游戏对象:

void CreateQuestToggles()
{
      foreach(Quest q in quests)
      {
     Toggle questToggle = Instantiate(QuestTogglePrefab, 
       QuestPanel.transform);
     questToggle.group =QuestPanel.GetComponent
       <ToggleGroup>();

     Text questToggleText = questToggle
       .GetComponentInChildren<Text>();
     questToggleText.text = q.Name;
     }
}

CreateQuestToggles()方法引用了一个名为ToggleScript的组件。这是一个Script组件,它是每个Toggle预制件的一部分。每次创建时,它的值都会被设置:

ToggleScript ts = questToggle.GetComponent<ToggleScript>();
ts.quest = q;
ts.DialogueText = DialogueText;
ts.ButtonPrefab = ButtonPrefab;
ts.OptionsPanel = OptionsPanel;
ts.ProgressPanel = ProgressPanel;
ts.ProgressButtonPrefab = ProgressButtonPrefab;

这个过程,从GetFiles()方法开始,首先基于Quest类创建对象。接下来,创建Toggle预制体,并将值传递给其Script组件。内部,Toggle预制体基于Dialogue类创建了一个额外的Script组件:

dialogue = gameObject.AddComponent<Dialogue>();
dialogue.quest = quest;
dialogue.DialogueText = DialogueText;
dialogue.ButtonPrefab = ButtonPrefab;
dialogue.OptionsPanel = OptionsPanel;

这种基于Quest类创建对象的多步骤过程,然后引导到Dialogue类的原因是允许每个Toggle预制体控制向玩家显示的内容:

图 11.2 – 显示给玩家的探索选择和选项

图 11.2 – 显示给玩家的探索选择和选项

当点击一个Toggle预制体时,根据Dialogue类,其对象会启用,向玩家显示当前文本和选项。这些选项反过来又基于Quest类的值,这些值传递给了Dialogue类。

代码的综合效果是创建独立的探索。根据每个由玩家选择的Toggle预制体确定的哪个是活动的,玩家将看到不同的对话选项,并在每个步骤结束时独立于其他探索展示出推进每个探索的能力。这结合了我们第一部分创建的 ink 模板和本节展示的多个探索方法,使用多个文件并增加了跨独立探索推进的能力。

在下一节中,我们将探讨如何通过在探索之间传递信息来奖励玩家的进度。这将基于本节创建的项目和第一部分的概念。

显示和奖励玩家进度

在编程中,有两种方法可以从一个系统访问另一个系统的值:轮询基于事件。要么检查一个值是否已更改(轮询),要么一个系统等待来自另一个系统的消息(事件)来指示值已更改。由于第二个系统必须等待事件发生,这通常被称为观察者模式,因为第二个系统正在观察事件。

在第一部分,我们看到了一个投票动作的例子。每当探索的步骤结束时,Unity 代码会检查(轮询)墨水值,以确定是否应该显示一个Button游戏对象并允许玩家推进探索。在第二部分,我们更接近基于事件的方法,其中在Quest类中使用ObserveVariable()方法。在第二个项目中,每当end墨水变量改变时,它会更新 Unity 中Quest类的End属性。由于这个值(End属性)被用作确定探索是否可以推进的一部分,这使得第二个项目比第一个项目更动态。

为了奖励玩家完成一个任务或作为另一个任务的一部分实现某些结果,需要在它们之间传递信息。由于墨迹运行时已经通过其名为 ObserveVariable()ObserveVariables() 的命名方法支持基于事件的访问方式,这使得这个过程稍微容易一些。然而,正如我们在上一节中介绍的,使用 Quest 类意味着每个墨迹故事现在都是相互独立的。

在本节中,我们将首先创建一种方式,让每个 Quest 类在玩家进行任务时共享变化。我们将以学习如何向玩家展示他们在完成不同任务时获得的信息结束。

提醒

本节完成的项目的示例可以在 GitHub 的 第十一章 *示例文件夹下找到,名称为 Chapter11-TrackingQuests。只会展示与该主题各部分中检查的概念相关的代码部分。

跟踪任务值

Story 类方法 ObserveVariables() 可以根据其名称跟踪不同的变量。然而,现有的墨迹模板包含了它用于跟踪进度的变量。这意味着跟踪任务值的第一个步骤是创建一个变量列表,排除作为扩展 Quest 类一部分的跟踪:

public Story InkStory;
public string Name;
public string Description;
public bool End;
public List<string> excludeVariables = new List<string>(){ "step", "steps", "name", "end" };

接下来,需要跟踪 variablesState 属性中包含的所有变量,除了用于跟踪任务进度的创建列表中的那些变量。这意味着对于每个变量,它可以被添加到一个单独的列表中,以便传递给名为 ObserveVariables()Story 方法。这可以是 ObserveVariables() 方法的部分,也可以是 Quest 方法的部分,以便与 Story 类上的对应方法相匹配:

public void ObserveVariables(Story.VariableObserver callback)
{
      List<string> variables = new List<string>();
      foreach(string n in InkStory.variablesState)
     {
           if(!excludeVariables.Contains(n))
           {
                variables.Add(n);
           }
     }
     InkStory.ObserveVariables(variables, callback);
}

添加到 Quest 类的新 ObserveVariables() 方法接受一个单一参数,Story.VariableObserver。内部,Story 类定义了一个名为 delegate 的方法,称为 VariableObserver。使用相同类型的新方法允许其他方法通过 Quest 方法传递到同名的 Story 方法。换句话说,新方法与现有方法的工作方式相同,但它将排除特定的一组变量名称。

在观察变量时,还需要有一种方法,在其中一个变量的值发生变化时,更新所有任务中变量的值。使用现有的 variablesState 属性,可以向 Quest 类添加一个名为 UpdateVariable() 的新方法:

public void UpdateVariable(string name, object value)
{
        if(InkStory.variablesState.GlobalVariableExistsWith
          Name(name))
     {
          if (!InkStory.variablesState[name].Equals(value))
           {
                InkStory.variablesState[name] = value;
           }
     }
}

UpdateVariable()方法中包含两个重要的检查。第一个使用GlobalVariableExistsWithName()方法。此方法检查变量是否存在。如果没有这个检查,如果一个任务添加了一个变量而另一个没有,整个项目可能会崩溃。第二个检查验证要更新的变量是否已经具有相同的值。如果没有这个第二个检查,更新变量将触发其他任何任务中的变量更改,这将触发另一个更新。这最终会导致崩溃,因为任务将尝试在无限循环中相互更新。

在两个新方法ObserveVariables()UpdateVariable()之间,还需要一个额外的部分:这两个方法必须结合使用。基于第二部分的工程,跨多个任务跟踪进度InkStoryScript代码是添加这个组合的最佳位置。这样做的原因是因为这将允许每个任务作为现有循环的一部分进行配置:

foreach(Quest q in quests)
{
     q.ObserveVariables((name, value) =>
     {
          UpdateAllQuests(name, value);
     });
}

这段新代码引用了一个额外的函数,UpdateAllQuests()。当传入一个变量的名称及其值时,这个新函数会遍历现有的任务,并通过为每个任务调用UpdateVariable()来更新它们的值:

void UpdateAllQuests(string name, object value)
{
     foreach (Quest q in quests)
     {
            q.UpdateVariable(name, value);
     }
}

在本节中,我们定义了多个新方法。我们向Quest类添加了两个名为ObserveVariables()UpdateVariable()的方法。这些方法使用事件方法来检测更改。ink 将在某个任务中的变量更改时向 Unity 发出信号。我们还通过使用名为UpdateAllQuests()的新方法在InkStoryScript中添加了代码,这个方法将更新其他任务中的相同变量。

在下一节中,我们将完成这个项目。检测更改并更新其他任务有助于在发生更改时保持所有任务更新。接下来,我们需要在发生更改时向玩家展示数据。

展示玩家进度

在上一节中,我们创建了必要的代码来保持跨任务使用的所有变量更新。为了向玩家展示这些数据,我们必须添加一个新的游戏对象名为StatisticsText。接下来,我们需要在 C#中使用一个特殊的关键字:static

在 C#中,使用static关键字的方法存在于类的任何实例之外。这意味着属性可以在项目的任何地方被访问或方法可以被调用。然而,这也带来一个主要的限制:任何static方法只能访问static属性。为了允许另一个类(Dialogue)能够调用InkStoryScript(其中包含所有任务)中的static方法,现有的quests和新StatisticsText变量都必须使用static关键字:

public GameObject QuestPanel;
public Toggle QuestTogglePrefab;
public Text DialogueText;
public Button ButtonPrefab;
public GameObject OptionsPanel;
public GameObject ProgressPanel;
public Button ProgressButtonPrefab;
public static GameObject StatisticsText;
static List<Quest> quests;

使用questsStatisticsText属性,它们可以通过一个名为ShowStatistics()的新方法被访问:

public static void ShowStatistics()
{
     StatisticsText = 
       GameObject.Find("/Canvas/Right/StatisticsText");
     Dictionary<string, object> vars = new
       Dictionary<string, object>();
     foreach (Quest q in quests)
     {
           foreach(string s in q.InkStory.variablesState)
           {
                if(!vars.ContainsKey(s) &&
                  !q.excludeVariables.Contains(s))
                {
                      vars.Add(s,
                        q.InkStory.variablesState[s]);
                }
           }
     }
     Text stats = StatisticsText.GetComponent<Text>();
     stats.text = "";
      foreach (KeyValuePair<string, object> entry in vars)
      {
           stats.text += entry.Key + ": " + entry.Value +
             "\n";
      }
}

新的ShowStatistics()方法使用Dictionary<string, object>。这结合了变量的名称(string)和其值(object)。然而,C#中的Dictionary存在一个障碍:它只能包含唯一的键。在ShowStatistics()方法中,使用ContainsKey()方法防止了这个问题。

为了设置一个可以被其他类调用的方法,新的ShowStatistics()代码必须放置在Dialogue类中,作为其UpdateContent()方法的一部分,在基于当前选择的创建Button游戏对象之后:

foreach (Choice in quest.InkStory.currentChoices)
{
     Button choiceButton = Instantiate(ButtonPrefab,
       OptionsPanel.transform);
     choiceButton.onClick.AddListener(delegate
     {
           quest.InkStory.ChooseChoiceIndex(choice.index);
           UpdateContent();
     });
     Text choiceText =       choiceButton.GetComponentInChildren<Text>();
       choiceText.text = choice.text;
}
InkStoryScript.ShowStatistics();

新的代码将始终显示变量的最新值,因为值被更新。由于每个任务都根据基于事件的方法来处理其变量的更新,任何用户动作,如做出选择或选择任务,都将更新项目跟踪的所有值,并不断显示玩家进度。

在本节中,我们通过向玩家展示更新的值来奖励玩家的进度。我们首先添加了上一节的一些代码,以不断更新所有任务中具有相同名称的变量。这保持了所有任务的连接。然后,我们创建了一个ShowStatistics()方法来显示这些值,并更新了一个Text游戏对象,以显示它们的名称和值。

摘要

我们通过创建墨迹模板来开始本章。通过在墨迹中定义变量和一个progress节点,我们可以像更大节点中的单个线迹一样,在任务的各个部分之间移动。接下来,我们研究了ChoosePathString()方法,它可以强制将故事移动到新的部分。

在第二部分,我们摆脱了单个文件,并开发了一个Quest类。基于Quest类的每个对象都包含一个基于不同文件的 inkStory对象和一个名为Progress()的方法,该方法内部调用ChoosePathString()方法。作为本部分的一部分,我们学习了QuestDialogue类如何帮助将功能组织到不同的类中。

最后,我们显示了变量的名称和值。首先,我们添加了新的方法,使用基于事件的方法来检测任何任务中的变量变化。这触发了其他任务中具有相同名称的其他变量的值更新。然后,我们添加了ShowStatistics()方法来显示这些更新的值。

在下一章,第十二章使用 ink 进行程序化叙事,我们将回顾程序化组织不同故事部分和内容的基本知识。在查看两种方法时,即直接在 ink 中编码值或动态将数据加载到 ink 中,我们将研究在特定情况下哪种方法可能更好。

问答

  1. 什么是任务?

  2. 用于根据本章中显示的墨迹模板推进任务的节点的名称是什么?

  3. ChoosePathString()方法是如何工作的?

  4. Unity 记录应用程序数据路径的全局属性叫什么名字?

  5. 轮询和基于事件的两种方法有什么区别?

第十二章:第十二章:使用 ink 进行程序化叙事

在本章中,我们将使用墨水和 Unity 回顾程序化叙事。Inkle 公司创建了并维护了叙事脚本语言 ink,并发布了多个结合 ink 和 Unity 的游戏。这些游戏使用程序化叙事,根据随机性和玩家的选择,为每个会话提供不同的体验。本章将介绍实现相同一般结果的方法:在 ink 中加载值和在 Unity 中编码集合。

在第一个主题中,我们将更广泛地回顾术语程序化叙事。基于最初在第三章中介绍的概念,“序列、循环和文本洗牌”,我们将学习如何使用墨水中的洗牌功能,根据简单的规则创建动态内容。

深入 ink,第二个主题将演示如何在 ink 中加载值。这个过程侧重于使用 ink 根据简单规则为玩家生成内容。

在最后一个主题中,我们将重点从 ink 转移到 Unity。我们将使用 Unity 来加载值并在 ink 中调用函数来处理值。这种方法在 Unity 中使用更复杂的代码,但在 ink 中使用更简单的代码。

在本章中,我们将涵盖以下主题:

  • 在墨水(ink)中引入程序化叙事

  • 将值加载到 ink 中

  • 在 Unity 中编码集合

技术要求

本章中的示例可以在 GitHub 上找到:github.com/PacktPublishing/Dynamic-Story-Scripting-with-the-ink-Scripting-Language/tree/main/Chapter12

在墨水(ink)中引入程序化叙事

术语程序化叙事的名字来源于另一个术语,程序化生成。在“生成”之前,“程序化”一词意味着内容是根据一系列程序(即规则)创建的。也就是说,规则。当提到生成资产,如 3D 模型或在某些游戏世界中的非玩家角色时,术语程序化生成适用。当讨论与交互项目中玩家的故事或体验叙事相关的规划、生成或动态排序内容时,更好的术语是程序化叙事

当一个项目使用规则来定义玩家如何与其故事内容的部分交互或遭遇时,就会发生程序化叙事。例如,如果一个项目有一套规则来为其角色创建动态名称,那么在科幻设定中的玩家可能会与生成的角色名称Neldronor互动,而另一个玩家可能会看到同一实体的名称Vynear。程序化生成故事内容也可以扩展到替换名称之外,决定玩家可以访问哪些任务,他们在遇到某些角色时,甚至在他们游戏会话中可能发生的事件。

在这个主题中,我们将介绍三个基于随机性的简单模式,以了解墨水中的程序生成。第一个,随机遭遇,将解释如何结合使用洗牌和墨水中的线程来创建玩家的可能遭遇列表。第二个模式,加权随机性,使用与第一个模式相同的概念,但定义了玩家可能看到某些内容的加权概率。这两种模式都是向现有项目添加简单程序生成而不破坏其现有结构的简单方法。最后一部分和模式,条件内容,将涵盖使用之前的行为和玩家的输入来影响玩家看到的遭遇。它是通过使用前两种模式的概念来做到这一点的。

随机遭遇

许多桌面角色扮演游戏使用一个称为随机表格的概念。在游戏的材料或书中,有一个表格或列表,列出了玩家可能在某个地点或场景中遇到的可能事物。游戏主持人会掷一些骰子,查阅表格以找到与掷出的数字匹配的行,然后告诉玩家他们遇到了什么。这个系统创造了随机遭遇的可能性。基于骰子的随机元素,玩家每次使用相同的表格生成游戏内容时,都会看到或与不同的事物互动。

在数字环境中,桌面游戏的随机表格可以成为一组可能的遭遇。在墨水中,我们可以使用洗牌和线程来创建这个:

{shuffle:
    - <- encounter.animal
    - <- encounter.machine
    - <- encounter.person
}

因为洗牌总是会随机选择其条目之一,所以每个线程发生的概率相同。然而,可能并不那么明显,每个可能的遭遇都是额外内容。与没有程序化叙事功能的、由作者大量创作的体验不同,即使是添加一个可能的遭遇表格这样的简单操作,也意味着为每个可能的遭遇创建新内容:

示例 1:

{shuffle:
    - <- encounter.animal
    - <- encounter.machine
    - <- encounter.person
}
== encounter
= animal
You hear a soft thud and then see a face peering at you. The sound starts as if it is a meow and then turns into language the longer you listen. "Meee-Hello. Sorry. I'm not used to talking to people.
-> DONE
= machine
The small machine buzzes to life in front of you. "Hi, there! I'm Ge8at10, but you can call me 'Great!'"
-> DONE
= person
You look around and see a man standing awkwardly against a tree. He waves and then looks away before speaking. "Uh. Yeah. Over here. Hi."
-> DONE

示例 1 展示了使用墨水中的洗牌,包含三个不同的线程,作为一个简单的随机遭遇系统。每个线程都引用一个名为 encounter 的结点,其针线以遭遇的类型命名,例如 animal。基于随机选择,玩家将看到三种可能遭遇中的一种。

使用随机遭遇,如定义在洗牌中,是向项目中添加简单程序化叙事的一种简单方法。这确实需要为洗牌中的每个条目添加额外内容,但这种模式对现有项目的影响最小。也有可能拥有额外的集合,并创建更动态的结果,玩家可能在多个设置、情境或级别中遇到任何数量的事物,每个使用都基于洗牌和墨水中的线程。

在下一节中,我们将检查加权概率作为控制一系列遭遇随机性的一个部分。与使用洗牌时所有条目都有相同发生概率的情况不同,可能存在某些遭遇应该更频繁地发生的情况。

加权随机性

洗牌是 ink 中强大的功能。它允许我们创建一组可能的条目,然后随机选择一个。正如在随机遭遇部分所看到的,可以创建一个简单的可能遭遇集;然后,可以使用洗牌按需选择一个。

然而,可能存在不需要等加权概率的情况。例如,开发者可能只想让玩家在游戏中穿越森林区域时 30%的时间遇到一个角色。对于这些情况,我们希望对遭遇的随机性进行加权

在 ink 中,RANDOM()函数允许我们定义产生的随机整数的范围。如果我们想要110之间的数字,我们可以使用RANDOM(1,10)。根据RANDOM()函数返回的数字,可以测试值,并且只有在结果在某个范围内时才采取行动:

示例 2:

VAR percentage = 0
~ percentage = RANDOM(1,10)
{
    - percentage <= 3: 
        <- encounter.brown_wizard
    - else:
        <- encounter.travel
}
== encounter
= brown_wizard
As you move through the forest, you encounter a strange man on a sled driven by large rabbits. You talk for a moment before the man moves away from you and deeper into the forest.
-> DONE
= travel
They travel through the forest.
-> DONE

示例 2中,只有在使用RANDOM(1, 10)的结果小于或等于3时,才会遇到brown_wizard针法。这为玩家遇到这个角色创造了 30%的机会。这是一个加权概率的例子。与两个遭遇之间的概率相等不同,其中一个比另一个更受重视。travel针法比其他针法brown_wizard更有可能被玩家遇到。

在上一节随机遭遇中,我们学习了如何在针法中创建不同的内容,并使用洗牌以相等的概率选择它们。在本节中,我们通过在 ink 中使用RANDOM()函数的加权概率来控制这种随机性。

在下一节中,我们将结合这种和之前的条件性内容模式。根据玩家之前选择的选项,我们可以通过随机性和比较其他值来影响玩家遇到的遭遇。

条件性内容

在不使用随机性的项目中,使用条件块或选择来响应玩家选择以及他们如何通过故事进展是很常见的。正如我们在随机遭遇加权随机性部分所看到的,我们也可以在 ink 中使用洗牌和RANDOM()函数来塑造故事。在本节中,我们将查看一个使用这两个概念结合来创建更复杂的程序以生成玩家之间内容连接的示例。

在上一节,加权随机性中,我们看到了如何在块内创建一组不同的条件语句来控制玩家接下来会遇到什么。在 示例 2 中,这是 brown_wizardtravel 线迹的加权结果,其中 travel 线迹更有可能被玩家看到。然而,玩家很少只想在游戏中阅读文本。他们希望对故事发生的事情有所输入。

通过使用墨水中的选择标签,我们可以测试玩家是否选择了特定的选项,然后影响玩家的加权结果:

示例 3:

VAR percentage = 0
A vast forest stretches out before you and alongside the forest is a winding river.
* (travel_forest) [Enter the forest]
* (travel_river) [Travel by river]
-
~ percentage = RANDOM(1,10)
{
    - percentage <= 3 && travel_forest == 1: 
        <- encounter.brown_wizard
    - percentage > 3 && travel_forest == 1:
        <- encounter.travel
    - travel_river == 1:
        <- encounter.river
}
== encounter
= brown_wizard
As you move through the forest, you encounter a strange man on a sled driven by large rabbits. You talk for a moment before the man moves away from you and deeper into the forest.
-> DONE
= travel
They travel through the forest.
-> DONE
= river
You travel down the river safely.
-> DONE

示例 3示例 2 的更新版本。然而,它不仅仅展示文本,玩家在编织中会看到两个选项。根据他们选择的哪一个,故事随后会沿着两条可能的路径分支。在第一种情况下,如果玩家选择在森林中旅行,他们有 30%的几率会遇到一个角色。在第二种情况下,如果玩家选择沿着河流旅行,他们将不会遇到这个角色。

虽然一些项目可能会使用洗牌或加权选项,但更多的项目会结合玩家的选择和过去的选项与随机性。这不仅让玩家对他们的体验有更多的控制权,还允许作者构建一个故事,以及某些可预测的结果。在只使用洗牌时,不是尝试解释多个结果,而是使用编织及其有限的选择数量来塑造未来遭遇的可能路径。因为波浪中只有两个选项,所以只有两个可能的主要分支,加权随机性只影响其中一个,不影响另一个。

在这个主题中,我们探讨了在 ink 项目中引入或调整简单的程序化叙事规则的三种不同模式。在第一部分,随机遭遇,我们学习了如何使用线程的洗牌来创建一组等权重的条目,以引入不同的故事内容。在第二部分,加权随机性,我们探讨了如何通过加权结果来控制随机性,其中一种结果比另一种更有可能。

在最后一节,条件内容中,我们将随机性与玩家选择选项的结果相结合,研究了如何创建看似更高级的规则,其中编织内的选择数量对故事形状的影响比任何分支中包含的随机性更强。

在下一个主题中,我们将探讨更复杂的模式。对于许多项目来说,ink 将是内容生成和项目如何使用程序化叙事的驱动力。我们将探讨如何将值加载到 ink 中,作为检查如何编写故事语法的部分,并计划玩家如何以动态方式遇到其不同部分。

将值加载到墨水中

程序化叙事的程序化方面可以主要存在于 ink 或 Unity 中。在这个主题中,我们将检查将值加载到 ink 中的过程。我们将集中设计,让 ink 在游戏会话期间对用户可能看到或与之交互的内容做出程序化决策。

在第一部分,替换语法中,我们将考虑如何使用我们在上一部分ink 中介绍程序化叙事中学到的知识来构建一套可能的玩家事件。这将引导我们进入下一部分故事规划,在那里我们将规则应用于语法本身。这将允许我们控制不同遭遇集如何受先前遭遇的影响,为复杂的故事创建简单的公式。

替换语法

在语言学中,语法描述了定义语言工作方式的规则。例如,在英语中,句子中有主语、谓语和宾语的具体顺序。在编程环境中,我们可以定义所谓的替换语法,其中一组规则描述了单词或短语如何被替换。这通常用于定义特定的顺序,例如在英语句子中使用主语和谓语。在编程环境中,可以产生动态构造,其中动态或随机值被替换在定义模式中的特定位置。

在 ink 中,我们可以创建函数来根据洗牌返回值。通过编写语法——即规定条目出现的顺序的规则——我们可以创建一个简单的替换模式,其中从特定集合中随机使用条目来创建动态文本交互:

示例 4:

First, we saw the {getLocation()}. Next, we visited the 
  {getMarker()}.
== function getLocation() ==
~ return "{~tower|ruin|temple}"
== function getMarker() ==
~ return "{~grave|farmstead|ancient tree}"

示例 4中,getLocation()getMarker() ink 函数提供了句子语法中的替换。通过在函数内放置洗牌操作并用引号包围它们,可以返回文本的结果;也就是说,在函数被调用时。因为所有函数都是全局的,这也意味着它们可以在代码中多次使用。

警告

根据示例 4,可能会诱使我们假设函数也可以用来使用洗牌生成可能的分叉目标。这不是事实。虽然变量可以持有分叉目标,但在 ink 中不允许函数进行分叉,并且语言阻止了调用函数和使用返回值来线程或分叉到故事中的另一个部分的组合。

ink 中的函数可以用于生成和返回文本。然而,由于其设计,ink 不允许函数控制故事流程。在每种洗牌操作可能还想使用分叉或线程的情况下,我们可以创建一个扩展的隧道,其中隧道的每一部分都作为语法的一部分:

示例 5:

 location -> marker -> DONE
== location
First, we saw the <>
{shuffle:
    - tower
    - ruin
    - temple
}<>.
->->
== marker
Next, we saw the <>
{shuffle:
    - grave
    - farmstead
    - ancient tree
}<>.
->->

示例 5 是使用扩展隧道对 示例 4 进行重写的一个版本。对于简单的文本替换,示例 4 中展示的模式,即使用洗牌和函数,可以非常实用。然而,示例 5 中的模式,即使用结和多行洗牌,允许洗牌中的每个条目可能改变或使用线程本身。这通常是创建替换语法的首选模式,其中语法的每个部分都可以根据需要扩展。

在本节中,我们学习了如何使用替换语法进行文本替换,然后是更高级的语法来整合隧道。在下一节中,我们将将替换语法作为故事规划过程的一部分来应用。循环和其他条件方面将被引入以创建更高级的替换语法。

故事规划

在上一节中,我们看到了替换语法如何为我们提供特定的事件顺序。通过使用洗牌,我们可以为每个部分选择随机的条目,为玩家创造动态体验。在上一节中展示的示例中,每个语法部分也只有一个条目。有一个用于 location,一个用于 marker,然后隧道结束。这很有用,但许多游戏将希望根据先前条目创建动态模式。换句话说,也有可能在语法内部基于先前的条目来设定未来条目的范围。

当我们创建一个公式,其中先前条目可以作为高级语法的部分影响未来条目时,我们正在使用一个称为 故事规划 的概念。在程序化叙事中,当故事根据生成比简单替换更复杂模式的规则进行规划时,就会发生故事规划。

如在 第三章 中所述,序列、循环和洗牌文本,替代方案可以嵌套在彼此内部。这意味着我们可以在多行条件块中使用替代方案,以创建基于先前值的上下文,从而选择随机的条目:

示例 6:

VAR location_pick = 0
-> location -> marker -> DONE
== location
First, we saw the <>
{shuffle:
    - tower
        ~ location_pick = "tower"
    - ruin
        ~ location_pick = "ruin"
    - temple
        ~ location_pick = "temple"
}<>.
->->
== marker
Next, we saw the <>
{
    - location_pick == "tower":
       {shuffle:
            - grave
            - memorial stone
        }
    - else:
        {shuffle:
            - farmstead
            - ancient tree
        }
}<>.
->->

示例 6 中,根据 示例 5 中的代码引入了一个新变量。在这个新版本中,marker 结的值基于 location_pick 变量。在从 location 结到 marker 结的扩展隧道中,location_pick 变量会改变。根据其值进入 marker 结,可以产生不同的结果。如果 location 的随机条目是 "tower",则前两个值 gravememorial stone 被启用。否则,farmsteadancient tree 的值被启用。

在本主题中,我们专注于在 ink 中加载和生成值。在第一部分,替换语法中,我们学习了如何创建简单的模式。在本节,叙事规划中,我们回顾了一个使用单个变量在隧道第二部分中进行分支的简单叙事规划示例。根据所需的规划,作者可以使用不同的变量创建非常复杂的语法,其中先前值可以分支出未来的计算和洗牌或其他替代方案中的条目范围。

在下一个主题中,我们将从墨水(ink)转向 Unity。当涉及到脚本化叙事体验时,ink 是一种令人难以置信的语言。然而,ink 在处理更复杂的值操作时并不适用。在 Unity 中,使用 C#,我们可以执行更复杂的程序化叙事方法,其中我们可以决定加载哪个 ink 故事以及如何传递其值以进行内部决策。

在 Unity 中编码集合

在上一个主题中,我们探讨了 ink 为玩家创建和规划内容的方法。在本节中,我们回到 Unity。在大型项目中,无论是故事还是其他内容,叙事内容通常会是游戏中的多个复杂互锁机制之一。在这些情况下,程序化叙事将是多个系统之一,Unity 作为推动项目的游戏引擎,将被编程为在更复杂的操作和规划中使用一个故事而不是另一个。在这些情况下,叙事内容存储在 C#称为集合的地方。这些可以是像数组这样简单的东西,也可以是更复杂的数据结构,能够根据模式或其内部元素的值对内部元素进行排序。

在第一部分,使用多个故事中,我们将查看一个示例,将项目的程序化叙事方面从 ink 移动到 Unity。我们不会在 ink 中使用洗牌(shuffles),而是在 Unity 中使用随机性来选择集合中不同的可能故事,然后从未来的选择中移除它们。这将使我们能够专注于 ink 中的故事内容,在单独的文件中创建对话或玩家选择,然后使用 Unity 来选择要向玩家展示的内容。

在最后一节,条件性地选择故事中,我们将使用 Unity 中的 ink 应用简单的叙事规划概念,正如叙事规划子节中所示。这与 ink 中的操作非常相似,这将使我们能够开始定义一个替换语法,以确定我们希望故事内容如何呈现给玩家,但由 Unity 中的编码集合执行选择部分的工作,而不是 ink。

使用多个故事

如我们首先在 第十一章 中探讨的,在 Quest Tracking and Branching Narratives(任务跟踪和分支叙事)主题中,在 Tracking progress across multiple quests(跟踪多个任务进度)这一主题中,可以使用多个墨迹文件作为项目中 Story 类的独立实例。在那个主题中,每个文件都是一个独立的故事。然而,也可以将每个文件用作更大故事中的一个场景。在这些情况下,每个墨迹文件将代表玩家的一种独立的叙事体验。一旦被 Unity 选择,它就可以作为会话的一部分或更长的故事,以随机顺序向玩家展示。

注意

本节完成的项目的示例可以在 GitHub 上的 第十二章 找到,名称为 Chapter12-MultipleStories。只会展示与正在考察的概念相关的代码部分。

当你使用多个墨迹文件将故事分解成不同的场景,每个场景可以独立访问时,一次性加载它们是最简单的方法。以下项目使用名为 GetFiles() 的方法处理编译后的 JSON 文件并创建 Story 类实例。每当创建一个新的对象时,它就会被添加到一个名为 StoriesList<Story> 集合中:

void GetFiles()
{
     string inkPath = Application.dataPath + "/Ink/";
     foreach (string file in Directory.GetFiles(inkPath,
       "*.json"))
     {
           string contents = File.ReadAllText(file);
           Stories.Add(new Story(contents));
     }
}

Introducing procedural storytelling in ink(介绍墨迹中的程序化叙事)主题的 Random encounter(随机遭遇)部分,使用了洗牌来在不同的线程之间进行选择。在 C# 中,Random 类的工作方式类似。它基于某个范围提供随机数据。使用其 Next() 方法以及集合的 Count 属性,它提供了一个索引来在 List<Story> 集合中选择条目,该集合由 GetFiles() 方法填充:

void PickRandomStory()
{
      if (Stories.Count > 0)
     {
           System.Random rand = new System.Random();
           int index = rand.Next(Stories.Count);
           Story entry = Stories[index];
           Stories.RemoveAt(index);
           UpdateContent(entry);
     }
     else
     {
           SceneDescription.text = "(There are no more
             stories.)";
     }
}

为了防止相同的故事再次出现,RemoveAt() 方法会随机地从 List<Story> 集合中移除条目。这防止了相同的故事被展示两次。

总的来说,Start() 方法用于调用多个其他方法来解析文件并随机选择一个故事。基于随机选择的 Story 中的编织内容,名为 UpdateContent() 的方法(由 PickRandomStory() 调用)向玩家提供两个选项,作为 Button 游戏对象。点击任何一个都会改变故事中变量的值。然后,这些更新作为两个变量的显示呈现给玩家,这两个变量在 Unity 中被跟踪:violence(暴力)和 peace(和平):

void Start()
{
     Stories = new List<Story>();
     UpdateStatistics();
     GetFiles();
     PickRandomStory();
}

虽然相对简单,但本节中展示的项目说明了在 ink 和 Unity 作为程序化叙事的独立系统之间平衡的重要方面。墨水故事的复杂性不会反映在选择它从集合中或显示其内容所需的 C#代码中。在 Unity 中,可以使用简单的代码随机选择一个墨水故事,该故事本身在其设计中使用随机性、替换语法或其故事规划。在 Unity 中,可以使用 C#的Random类,而不需要了解墨水故事正在做什么。

在下一节中,我们将遵循与我们在“在 ink 中引入程序化叙事”主题中所做的类似动作。在本节中,我们专注于使用多个 ink 故事与 C#的Random类,并在它们之间进行平等选择。然而,大多数项目只想根据先决条件选择墨水故事。在下一节中,我们将探讨在墨水故事之间进行条件选择。

条件选择故事

在上一节中,我们看到了 C#的Random类如何允许我们根据Story类作为集合List<Story>的一部分来选择对象。这对大多数项目来说作用有限。相反,大多数开发者更希望控制何时选择一个墨水故事以及它成为可用的条件。在本节中,我们将探讨一个简单系统的实现,该系统在加载任何内容之前会检查故事的先决条件。值将在集合中的故事之间跟踪,如果满足故事的先决条件,它将被视为可用。如果不满足,则会被忽略。

注意

本节完成的项目的示例可以在 GitHub 上的第十二章示例中找到,名称为Chapter12-ConditionalStories。将仅展示与正在检查的概念相关的代码部分。

要检查墨水故事的先决条件,需要一个单独的类,ConditionalStory,它包含墨水故事以及原本出现在第十一章中“显示和奖励玩家进度”部分的“跟踪任务值”子部分的ObserveVariables()UpdateVariable()方法:

public void ObserveVariables(Story.VariableObserver
  callback)
{
      InkStory.ObserveVariables(new List<string>() {
        "violence", "peace" }, callback);
}
public void UpdateVariable(string name, object value)
{
      if(InkStory.variablesState.
        GlobalVariableExistsWithName(name))
      {
          if (!InkStory.variablesState[name].Equals(value))
          {
                 InkStory.variablesState[name] = value;
          }
      }
}

ConditionalStory类有一个名为Available()的方法。内部,它使用Story类的EvaluateFunction()方法来调用一个名为check()的 ink 函数。假设墨水故事包含该函数,它将被调用,并将结果转换为布尔值:

public bool Available()
{
     bool result = false;
     if(InkStory.HasFunction("check"))
     {
           result = (bool)
              InkStory.EvaluateFunction("check");
     }
     return result;
}

每个故事文件都有一个条件检查,该检查被反馈到ConditionalStory类的Available()方法中。如果check() ink 函数返回true,则故事可用于使用。

对上一节中显示的代码进行了各种修改,使用多个故事。第一个是使用ConditionalStory作为一个包含基于Story类的对象的类。第二个是SelectStories()方法。与随机选择条目不同,它使用List<ConditionalStory>FindAll()方法搜索其条目。如果Available()方法(每次调用时都会调用 ink 中的check()函数)报告true,则认为故事是可用的:

List<ConditionalStory> selection = Stories.FindAll(e => 
  e.Available());
if (selection.Count > 0)
{
      System.Random rand = new System.Random();
      int index = rand.Next(selection.Count);
      ConditionalStory entry = selection[index];
      Stories.Remove(entry);
      UpdateContent(entry);
}

如果每个墨迹故事都定义了它是否以及如何可用于更大的项目,这允许墨迹和 Unity 中的 C#代码分别开发。为了使其可用以便选择,ink 中的check()函数必须向 C#中的ConditionalStory类报告true。这创建了一个简单但易于重复的模式,用于在 Unity 中创建基于对如何使用FindAll()方法(如本节所述)和Random类(如前节所述)的集合工作原理的理解的基于条件的故事。

摘要

本章的目标不是解决程序化叙事的所有问题或涵盖所有可能的算法。第一个主题,在 ink 中介绍程序化叙事,回顾了重要概念,例如随机性如何在 ink 中选择内容中发挥作用。第二个部分,将值加载到 ink 中,探讨了如何使用 ink 结合更高级的概念,如语法和故事规划。最后,在在 Unity 中编码集合这一主题中,我们看到了 Unity 如何用于在集合中随机选择 ink 故事(第一部分),以及如何通过 ink 故事和 Unity 中的 C#类之间的通信结合一些简单的条件测试。

我们现在已经完成了这本书的最后一章,希望你能带着不同的概念去探索,以及一些简单的模式来用于更高级的项目。程序化叙事是一个多样且深奥的主题。许多研究人员和开发者已经创建并继续探索构建替换语法的可能方法、规划故事以及使用 ink 和 Unity(单独或一起)为玩家制作复杂故事和体验的简单规则。

问题

  1. 什么是程序化叙事?

  2. 什么是随机表?

  3. 什么是加权随机性?

  4. 什么是替换语法?

  5. 什么是故事规划?

第十三章:评估

本节包含所有章节的问题答案。

第一章 – 文本、流程、选择和编织

  1. 故事是内容,叙述是体验。在非线性叙事中,故事可能以不同的顺序体验,每次重新排序都会为读者创造一个新的叙述。

  2. ink 将流程理解为作为类似叙述体验的故事中的移动。在 ink 中,当没有通往故事结尾的路径时,这可能会“耗尽”。

  3. 可以使用粘合剂将多行组合在一起,粘合剂是小于和大于符号的组合。

  4. 编织是一系列选择的集合。

  5. 不同的选择类型有基本型、通常称为消失型选择和粘性选择。第一种只能使用一次,第二种可以多次使用,因为它们“粘附”在编织使用中。

  6. 选择性输出允许作者根据 ink 中选择的文本选择在塑造选项时要使用的内容。可以根据使用开闭方括号和选择文本的文本显示给读者的不同数量的文本。

  7. 粘性选择保持选项打开以供以后使用。在更复杂的故事中,读者可能返回到编织并选择不同的或相同的选项。

第二章 – 节点、引导和循环模式

  1. 节点是故事的一个部分,有一个名字,可以被引导到墨迹中。

  2. DONE 结束当前流程,END 完全停止故事。

  3. 缝是故事的一个子部分,只能出现在节点内。

  4. INCLUDE 关键字将其他文件拉入,允许项目使用具有自己节点和缝的多个文件,这些节点和缝对整个项目都是可用的。

  5. 标记的选项在每次显示时都会创建一个增加的值。另一方面,条件值允许比较变量和值。如果使用选项时条件为真,它将被显示。否则,它将被隐藏。

第三章 – 序列、循环和文本洗牌

  1. 三种替代类型是序列、循环和洗牌。

  2. 序列、循环和洗牌的单行形式都使用元素之间的垂直线 |

  3. 在第一个元素之前使用 & 符号作为创建循环的替代。

  4. 序列将显示每个元素直到最后一个。循环将在最后一个元素之后返回到第一个元素。

  5. 洗牌在每次运行时都会从其集合中随机选择一个元素。

  6. 多行序列使用 stopping 关键字。这与循环和洗牌不同,它们各自使用替代类型的名称作为关键字来创建它们的多行形式。

第四章 – 变量、列表和函数

  1. 赋值发生在变量被赋予新值时。这发生在变量在墨迹中首次创建时,也可能发生在单行代码中。

  2. 基于两个字符串相加或字符串与数值之间创建的新字符串称为连接。

  3. 波浪号定义了 ink 中的一行代码。它通常与赋值、调用函数或执行其他单行操作一起使用。

  4. 列表中的值是布尔集的一部分。这意味着它们是truefalse。在 ink 中,默认情况下,列表中的所有值都设置为false。要更改为true,它们必须被括号包围。

  5. 将作为函数或结的一部分定义的变量称为参数。它影响计算或函数如何处理内容。

第五章 – 地道和线程

  1. 要创建地道,必须在结或缝合的名称前后使用转向。在结或缝合内部,必须一起使用两个转向以从地道返回。

  2. 地道连接墨水中的两个不同位置。它们可以在故事中的结、缝合或其他位置之间使用。地道将流动移动到某个位置,然后在遇到两个转向时返回。

  3. 转向将流动移动到另一个结或缝合。地道使用两个转向移动到结或缝合,然后返回到起始位置。线程是转向的逆。它将结或缝合移动到当前流动位置,而不是将流动移动到结或缝合。

  4. 通常,同一行上不能使用多个线程。然而,当使用替代方案时,可以将多个线程作为同一结构的一部分包含在内。它们仍然逐个访问,但可以在同一行上分组。

第六章 – 添加和使用 ink-Unity 集成插件

  1. 不,Inkle,ink-Unity 集成插件的维护者,不建议使用在 Unity Asset Store 中找到的版本。这个版本通常过时。

  2. 当在项目中安装 ink-Unity 集成插件时,可以使用创建菜单创建新的 ink 文件。这可以通过项目窗口工具栏、在项目窗口中右键单击或通过资产菜单选择创建来访问。

  3. Inky 是编辑 ink 源文件的不错选择。然而,它需要与 ink 源文件关联,这些文件可以通过在项目窗口中双击文件来打开。

  4. 是的,可以通过打开项目设置、选择ink然后更改自动编译所有 ink选项来调整自动编译过程。

第七章 – Unity API – 制作选择和故事进展

  1. Continue()方法仅在每次调用时加载一行 ink 文本内容和遇到的下一个编织。ContinueMaximally()方法加载所有文本内容,直到遇到编织或故事的结尾。

  2. ChooseChoiceIndex()方法期望在Story类中currentChoices属性的条目总数范围内的int值。

  3. canContinue 属性是一个布尔值。如果有更多的剧情内容,它将是 true。否则,它将是 false。在使用 Continue()ContinueMaximally() 方法之前,应始终将 canContinue 属性作为条件语句的一部分进行检查,以防止任一方法抛出错误。

  4. Prefab 是在 Unity 中保存为资产的 GameObject 实例。任何用作 Hierarchy 视图一部分的游戏对象都可以通过将其拖入 Project 窗口来保存为资产。在 Unity 中,可以通过称为 实例化 的过程在运行时创建 prefab 的副本。

  5. 对于在运行的 ink 剧情中遇到的编织内容,currentChoices 属性将包含一个 List<Choice> 实例,其中每个条目都是基于 Choice 类的对象,具有 textindex 属性。

第八章 – Story API – 访问 ink 变量和函数

  1. 是的,一旦在 ink 中创建了一个变量,就可以在任何剧情点访问它。通过使用 Story API 的一部分 variablesState 属性,也可以访问和更改变量的值。

  2. 由于函数在 ink 中是全局的,这意味着可以从 ink 剧情的任何点访问它们。当使用 ink-Unity Integration 插件时,Story API 中的 HasFunction()EvaluateFunction() 方法提供了在 ink 剧情中测试全局函数并评估它的能力。EvaluateFunction() 方法调用 ink 函数,并可以使用 out C# 关键字将数据传递给 ink 或检索函数的文本输出。

  3. 与文本内容不同,ink 中变量的值存在于由 Continue() 方法或 ContinueMaximally() 方法控制的剧情进展之外。然而,由于变量是全局的,它们在加载一行或更大块的故事内容时可以被更改。变量的值可能会因为使用 Continue() 方法或 ContinueMaximally() 方法而改变,但它们在技术上并不是处理 ink 中的变量所必需的。

  4. 在使用 Story API 中的 variablesState 属性时,可以通过在方括号内使用引号包裹的变量名来访问任何变量。虽然相同的 API 提供了在 ink 中处理变量的方法,但简写语法通常是访问和更改变量值的首选方式。

  5. 是的。在尝试更改变量值或评估函数之前,建议使用 HasFunction() 方法来处理函数。这将有助于防止可能的游戏崩溃问题。

  6. out C# 关键字提供了一种通过引用传递变量而不是仅传递其值的方法。这是将 ink 函数的文本输出作为 Story API 的 EvaluateFunction() 方法的参数检索的一种简单方式。

第九章 – Story API – 观察和响应剧情事件

  1. ObserveVariable()ObserveVariables()方法基于对变量观察动作的使用。这使 Unity 的反应与 ink 中的动作分离。观察动作允许 Unity 以任何它想要的方式做出反应。这些方法只提供变量的名称及其新值。

  2. 委托函数是ObserveVariable()ObserveVariables()方法的第二个参数。使用delegate C#关键字将函数的运行委托给另一个函数或方法。ObserveVariable()ObserveVariables()方法用于回调方法。

  3. 第一个方法是ObserveVariable(),它接受一个变量的名称和一个在变量变化时被调用的委托函数。第二个方法是ObserveVariables(),它接受一个要监视的变量List<string>实例和一个委托函数。在这两种情况下,委托函数都会被调用,并传入变化变量的名称及其新值。

  4. variablesState属性通过名称直接访问 ink 变量及其当前值。然而,必须使用 ink 变量的名称来访问其值,作为重复代码的一部分,例如在 Unity 中的Update()FixedUpdate()方法中。ObserveVariable()ObserveVariables()方法允许开发者编写只有在一个或多个 ink 变量发生变化时才运行的代码,并且仅在那时运行。这可以在每个周期中节省时间,只运行必要的代码,然后在 ink 本身更新某些值时更新 Unity。

第十章 – 使用 ink 的对话系统

  1. 当在墨迹中单行上使用哈希(#)来创建标签时,会创建一个哈希标签。哈希标签用于在每行添加额外数据。

  2. 哈希标签只能用于行尾,但说话标签通常用于行首。说话标签总是用来标记谁在进行交流,但哈希标签传达任何形式的其他信息。

  3. 对话的分支模式通常看起来像树,初始的选项集合看起来像“树干”,每个分支向外扩展到它自己的集合中。

  4. 列表模式以垂直排列的方式呈现选项。它可以显示每个选项的多个句子,但通常需要滚动条来呈现集合中的所有选项。当有更多视觉空间用于对话选项时,它最好使用。

  5. 径向菜单模式以顺时针方向在屏幕上呈现选项。它通常与视频游戏控制台或其他有限视觉空间环境一起使用。由于空间减少,选项通常以单个单词、图标或其结果的简短描述的形式出现。

第十一章 – 任务跟踪和分支叙事

  1. 任务是一系列与故事中角色相关的事件。

  2. 用于推进任务的 ink 节点名称是progress

  3. ChoosePathString()方法会突然将当前位置从一个部分移动到另一个部分。

  4. Unity 中的全局属性命名为Application.dataPath

  5. 投票需要检查一个系统中的值与另一个系统中的值。基于事件的方法允许一个系统观察另一个系统,并在事件发生时对其做出响应。

第十二章 – 使用 ink 进行程序化叙事

  1. 程序化叙事发生在项目的叙事是通过程序或规则生成的,这些程序或规则动态地规划或塑造为玩家提供的内容时。

  2. 随机表是一组条目,其中个别值是随机选择的。最初作为使用骰子选择行的表格创建,这个概念可以在 ink 中使用洗牌来实现。

  3. 可以使用RANDOM()函数来决定条目的概率,而不是在启用洗牌功能时使用默认的等量分配。

  4. 语法是一套语言的规则。替换语法根据一套规则决定单词或短语的替换。通常,替换语法会与随机条目或根据条件规则一起使用。

  5. 故事规划是基于规则对故事内容进行排序。故事规划基于使用替换语法来决定玩家可能会体验项目中的哪些部分,无论是在游戏开始之前还是由于某些玩家行为。

Packt.com

订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及领先的行业工具,帮助你规划个人发展并提升职业生涯。更多信息,请访问我们的网站。

第十四章:为什么订阅?

  • 使用来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,多花时间编码

  • 通过为你量身定制的技能计划提高学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于快速访问关键信息

  • 复制粘贴、打印和收藏内容

你知道 Packt 为每本书都提供电子书版本,包括 PDF 和 ePub 文件吗?你可以在 packt.com 升级到电子书版本,并且作为印刷书客户,你有权获得电子书副本的折扣。有关更多信息,请联系我们 customercare@packtpub.com。

www.packt.com,你还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

你可能还会喜欢以下书籍

如果你喜欢这本书,你可能对 Packt 出版的以下其他书籍感兴趣:

《Unity 2021 游戏开发实战 - 第二版

Nicolas Alejandro Borromeo

ISBN: 9781801071482

  • 探索 C# 和视觉脚本工具,以自定义游戏的各种方面,例如物理、游戏玩法和 UI

  • 使用 Unity 的新 Shader Graph 和通用渲染管线编写丰富的着色器和效果

  • 实现后处理,使用全屏效果提高图形质量

  • 使用 VFX Graph 和 Shuriken 从头开始为你的 Unity 游戏创建丰富的粒子系统

  • 使用 Animator、Cinemachine 和 Timeline 为你的游戏添加动画

  • 使用全新的 UI 工具包创建用户界面

  • 实现游戏 AI 以控制角色行为

《Unity 2021 游戏开发模式 - 第二版

David Baron

ISBN: 9781800200814

  • 使用行业标准开发模式构建专业的 Unity 代码

  • 识别实现特定游戏机制或功能的正确模式

  • 开发可配置的核心游戏机制和元素,无需编写代码即可修改

  • 审查实用的面向对象编程(OOP)技术,并了解它们在 Unity 项目中的应用

  • 构建独特的游戏开发系统,例如关卡编辑器

  • 探索如何将传统设计模式适应 Unity API 的使用

Packt 正在寻找像你这样的作者

如果你有兴趣成为 Packt 的作者,请访问authors.packtpub.com并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。

分享你的想法

现在你已经完成了《使用 ink 脚本语言进行动态故事脚本编写》,我们非常想听听你的想法!如果你在亚马逊购买了这本书,请点击此处直接跳转到该书的亚马逊评论页面并分享你的反馈或在该购买网站上留下评论。

你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

你可能还会喜欢的其他书籍

posted @ 2025-10-23 15:07  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报